diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx index 6674ca94..ef5791b4 100644 --- a/src/components/auth/SignInForm.tsx +++ b/src/components/auth/SignInForm.tsx @@ -75,6 +75,16 @@ export function SignInForm() { password: formData.password, }); localStorage.setItem("auth_token", response.token); + // Persist the logged-in user's id so messaging can identify the + // current user (e.g. to hide "Message owner" on your own listings). + localStorage.setItem( + "petad_user", + JSON.stringify({ + id: response.user.id, + email: response.user.email, + fullName: response.user.fullName, + role: response.user.role, + }), // Persist the profile so forms (e.g. the adoption applicant step) // can prefill the signed-in user's details. localStorage.setItem( diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 7a076981..4ef880f3 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -7,6 +7,7 @@ import { useRoleGuard } from "../../hooks/useRoleGuard"; import { DeferredNotificationCentreDropdown } from "../notifications"; import { ThemeToggle } from "../theme-toggle"; import { FreighterWalletButton } from "../wallet/FreighterWalletButton"; +import { MessagesLink } from "../../features/messaging/components/MessagesLink"; const baseNavLinks = [ { label: "Home", path: "/home", icon: House }, @@ -86,6 +87,8 @@ export function Navbar() { + + diff --git a/src/features/messaging/__tests__/MessageComposer.test.tsx b/src/features/messaging/__tests__/MessageComposer.test.tsx new file mode 100644 index 00000000..07876911 --- /dev/null +++ b/src/features/messaging/__tests__/MessageComposer.test.tsx @@ -0,0 +1,163 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; +import { server } from "../../../mocks/server"; +import { clearAllDrafts } from "../draftStore"; +import { clearOutbox } from "../hooks/useSendMessage"; +import { messageKeys } from "../hooks/useMessages"; +import type { MessagesCache } from "../messageCache"; +import { MessageComposer } from "../components/MessageComposer"; +import { createWrapper, file, msg, signIn } from "./testUtils"; + +function renderComposer(conversationId = "c1") { + const { queryClient, wrapper } = createWrapper(); + for (const id of ["c1", "c2"]) { + queryClient.setQueryData(messageKeys.thread(id), { + pages: [{ data: [msg({ conversationId: id })] }], + pageParams: [undefined], + }); + } + const view = render(, { wrapper }); + return { + queryClient, + ...view, + switchTo: (id: string) => view.rerender(), + }; +} + +function captureSends() { + const bodies: Array<{ body: string; clientId: string }> = []; + server.use( + http.post("*/api/conversations/:id/messages", async ({ request }) => { + const json = (await request.json()) as { body: string; clientId: string }; + bodies.push(json); + return HttpResponse.json(msg({ id: `srv-${bodies.length}`, body: json.body, clientId: json.clientId }), { status: 201 }); + }), + ); + return bodies; +} + +describe("MessageComposer", () => { + beforeEach(() => { + signIn(); + clearAllDrafts(); + clearOutbox(); + }); + + it("disables Send when the message is empty or whitespace", async () => { + const user = userEvent.setup(); + renderComposer(); + const send = screen.getByRole("button", { name: "Send message" }); + expect(send).toBeDisabled(); + + await user.type(screen.getByLabelText("Message"), " "); + expect(send).toBeDisabled(); + + await user.type(screen.getByLabelText("Message"), "hi"); + expect(send).toBeEnabled(); + }); + + it("Enter sends and clears the box", async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderComposer(); + const box = screen.getByLabelText("Message"); + + await user.type(box, "hello there{Enter}"); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0].body).toBe("hello there"); + expect(box).toHaveValue(""); + }); + + it("Shift+Enter inserts a newline and does not send", async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderComposer(); + const box = screen.getByLabelText("Message"); + + await user.type(box, "line one{Shift>}{Enter}{/Shift}line two"); + + expect(box).toHaveValue("line one\nline two"); + expect(bodies).toHaveLength(0); + }); + + it("does not send an empty message with Enter", async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderComposer(); + await user.type(screen.getByLabelText("Message"), "{Enter}"); + expect(bodies).toHaveLength(0); + }); + + it("keeps a draft per conversation across switching threads", async () => { + const user = userEvent.setup(); + const { switchTo } = renderComposer("c1"); + + await user.type(screen.getByLabelText("Message"), "draft for one"); + switchTo("c2"); + expect(screen.getByLabelText("Message")).toHaveValue(""); + + await user.type(screen.getByLabelText("Message"), "draft for two"); + switchTo("c1"); + expect(screen.getByLabelText("Message")).toHaveValue("draft for one"); + switchTo("c2"); + expect(screen.getByLabelText("Message")).toHaveValue("draft for two"); + }); + + it("clears the stored draft once the message is sent", async () => { + captureSends(); + const user = userEvent.setup(); + const { switchTo } = renderComposer("c1"); + await user.type(screen.getByLabelText("Message"), "sent one{Enter}"); + switchTo("c2"); + switchTo("c1"); + expect(screen.getByLabelText("Message")).toHaveValue(""); + }); + + describe("attachments", () => { + it("rejects the wrong type with specific copy", async () => { + const user = userEvent.setup({ applyAccept: false }); + renderComposer(); + await user.upload(screen.getByTestId("attachment-input"), file("cv.docx", "application/msword")); + + expect(await screen.findByRole("alert")).toHaveTextContent( + '"cv.docx" is a .docx file. Only images (JPG, PNG, WebP, GIF) and PDFs can be attached.', + ); + expect(screen.queryByLabelText("Attachments to send")).not.toBeInTheDocument(); + }); + + it("rejects oversized files with specific copy", async () => { + const user = userEvent.setup({ applyAccept: false }); + renderComposer(); + await user.upload(screen.getByTestId("attachment-input"), file("huge.png", "image/png", 6 * 1024 * 1024)); + expect(await screen.findByRole("alert")).toHaveTextContent('"huge.png" is 6.0 MB. Each attachment must be 5.0 MB or smaller.'); + }); + + it("shows chips, lets you remove one, and caps at 3", async () => { + const user = userEvent.setup({ applyAccept: false }); + renderComposer(); + const input = screen.getByTestId("attachment-input"); + + await user.upload(input, [file("a.png", "image/png"), file("b.pdf", "application/pdf")]); + expect(screen.getByText("a.png")).toBeInTheDocument(); + expect(screen.getByText("b.pdf")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Remove a.png" })); + expect(screen.queryByText("a.png")).not.toBeInTheDocument(); + + await user.upload(input, [file("c.png", "image/png"), file("d.png", "image/png"), file("e.png", "image/png")]); + expect(await screen.findByRole("alert")).toHaveTextContent('You can attach up to 3 files per message. "e.png" was not added.'); + expect(within(screen.getByLabelText("Attachments to send")).getAllByRole("listitem")).toHaveLength(3); + expect(screen.getByRole("button", { name: "Attach files" })).toBeDisabled(); + }); + + it("enables Send for attachments alone", async () => { + const user = userEvent.setup({ applyAccept: false }); + renderComposer(); + await user.upload(screen.getByTestId("attachment-input"), file("only.pdf", "application/pdf")); + expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled(); + }); + }); +}); diff --git a/src/features/messaging/__tests__/MessageThread.test.tsx b/src/features/messaging/__tests__/MessageThread.test.tsx new file mode 100644 index 00000000..62f94884 --- /dev/null +++ b/src/features/messaging/__tests__/MessageThread.test.tsx @@ -0,0 +1,269 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { server } from "../../../mocks/server"; +import { resetMessagesMock } from "../../../mocks/handlers/messages"; +import { MessageThread } from "../components/MessageThread"; +import { messageKeys } from "../hooks/useMessages"; +import { clearOutbox } from "../hooks/useSendMessage"; +import { flattenMessages, upsertMessage, type MessagesCache } from "../messageCache"; +import { installFakeLayout, ROW, VIEWPORT } from "./fakeLayout"; +import { createWrapper, ME, msg, signIn } from "./testUtils"; + +let restoreLayout: () => void; +beforeEach(() => { + signIn(); + clearOutbox(); + resetMessagesMock(); + restoreLayout = installFakeLayout(); +}); +afterEach(() => restoreLayout()); + +function seed(messages: ReturnType[], conversationId = "c1") { + const ctx = createWrapper(); + ctx.queryClient.setQueryData(messageKeys.thread(conversationId), { + pages: [{ data: messages }], + pageParams: [undefined], + }); + render(, { wrapper: ctx.wrapper }); + return ctx; +} + +const scroller = () => screen.getByTestId("message-scroll"); + +describe("MessageThread — layout & rendering", () => { + it("groups messages by day with separators", () => { + seed([ + msg({ createdAt: new Date(2026, 0, 10, 9, 0).toISOString() }), + msg({ createdAt: new Date(2026, 0, 10, 18, 0).toISOString() }), + msg({ createdAt: new Date(2026, 0, 11, 8, 0).toISOString() }), + ]); + const separators = screen.getAllByTestId("day-separator"); + expect(separators).toHaveLength(2); + expect(separators[0]).toHaveTextContent("Saturday, 10 Jan 2026"); + expect(separators[1]).toHaveTextContent("Sunday, 11 Jan 2026"); + }); + + it('labels the current day "Today"', () => { + seed([msg({ createdAt: new Date().toISOString() })]); + expect(screen.getByTestId("day-separator")).toHaveTextContent("Today"); + }); + + it("aligns bubbles by senderId vs the current user", () => { + seed([msg({ senderId: ME.id, body: "mine" }), msg({ senderId: "user-other", body: "theirs" })]); + const [mine, theirs] = screen.getAllByTestId("message"); + expect(mine).toHaveClass("items-end"); + expect(theirs).toHaveClass("items-start"); + expect(within(theirs).getByText("Jane")).toBeInTheDocument(); // sender name only on incoming + }); + + it("starts scrolled to the newest message", () => { + seed(Array.from({ length: 12 }, () => msg())); + expect(scroller().scrollTop).toBe(scroller().scrollHeight); + }); + + it("shows an empty state", () => { + seed([]); + expect(screen.getByText(/No messages yet/)).toBeInTheDocument(); + }); + + describe("attachments", () => { + const image = { id: "a1", fileName: "buddy.png", mimeType: "image/png", size: 100, url: "data:image/png;base64,AAAA" }; + const pdf = { id: "a2", fileName: "vaccines.pdf", mimeType: "application/pdf", size: 2048 }; + + it('gives images meaningful alt text ("Image sent by Jane")', () => { + seed([msg({ senderName: "Jane", attachments: [image] })]); + expect(screen.getByAltText("Image sent by Jane")).toHaveAttribute("src", image.url); + }); + + it('uses "you" for your own images', () => { + seed([msg({ senderId: ME.id, senderName: "Me Myself", attachments: [image] })]); + expect(screen.getByAltText("Image sent by you")).toBeInTheDocument(); + }); + + it("opens a preview on click and closes it with Escape", async () => { + const user = userEvent.setup(); + seed([msg({ senderName: "Jane", attachments: [image] })]); + await user.click(screen.getByRole("button", { name: "Preview image buddy.png" })); + + const dialog = screen.getByRole("dialog", { name: "Image preview" }); + expect(within(dialog).getByAltText("Image sent by Jane")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders non-images as file cards and downloads via a short-lived URL", async () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + const user = userEvent.setup(); + seed([msg({ id: "m-pdf", attachments: [pdf] })]); + + expect(screen.getByText("vaccines.pdf")).toBeInTheDocument(); + expect(screen.getByText("2.0 KB")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Download vaccines.pdf" })); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + expect(open.mock.calls[0][0]).toMatch(/files\.example\.com\/m-pdf\/a2\?sig=short-lived/); + open.mockRestore(); + }); + + it("shows an error when the download link can't be fetched", async () => { + server.use(http.get("*/api/messages/:m/attachments/:a/url", () => HttpResponse.json({}, { status: 500 }))); + const user = userEvent.setup(); + seed([msg({ attachments: [pdf] })]); + await user.click(screen.getByRole("button", { name: "Download vaccines.pdf" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Couldn't get the download link"); + }); + }); + + describe("delivery state", () => { + it('shows "Sending…" for in-flight messages', () => { + seed([msg({ senderId: ME.id, status: "sending", clientId: "c-1", id: "tmp-c-1" })]); + expect(screen.getByText("Sending…")).toBeInTheDocument(); + }); + + it("shows Retry and Delete on failure; Delete removes the bubble", async () => { + const user = userEvent.setup(); + seed([msg({ senderId: ME.id, status: "failed", clientId: "c-2", id: "tmp-c-2", body: "oops" })]); + expect(screen.getByText("Failed to send")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.queryByText("oops")).not.toBeInTheDocument(); + }); + }); +}); + +describe("MessageThread — infinite scroll upward", () => { + /** Renders the real mocked thread (conv-1: 55 messages, pages of 20). */ + async function renderServerThread() { + const ctx = createWrapper(); + render(, { wrapper: ctx.wrapper }); + await screen.findByRole("log"); + await waitFor(() => expect(screen.getAllByTestId("message").length).toBe(20)); + return ctx; + } + + const bubbleIds = () => screen.getAllByTestId("message").map((el) => el.getAttribute("data-message-id")!); + + it("does not jump the viewport when older messages load (anchors on the previous first message)", async () => { + await renderServerThread(); + const box = scroller(); + const firstBefore = bubbleIds()[0]; + const anchorEl = () => box.querySelector(`[data-message-id="${firstBefore}"]`)!; + + // User scrolls to the very top. + act(() => { + box.scrollTop = 0; + fireEvent.scroll(box); + }); + const offsetBefore = anchorEl().offsetTop - box.scrollTop; + + await waitFor(() => expect(screen.getAllByTestId("message").length).toBe(40)); + expect(bubbleIds()[0]).not.toBe(firstBefore); // older messages were prepended + + // The same message sits the same distance from the top edge: no jump. + expect(anchorEl().offsetTop - box.scrollTop).toBe(offsetBefore); + expect(box.scrollTop).toBeGreaterThan(0); + }); + + it("loads older pages until the history is exhausted, then offers no more", async () => { + await renderServerThread(); + const box = scroller(); + for (const expected of [40, 55]) { + act(() => { + box.scrollTop = 0; + fireEvent.scroll(box); + }); + await waitFor(() => expect(screen.getAllByTestId("message").length).toBe(expected)); + } + expect(screen.queryByRole("button", { name: /older messages/i })).not.toBeInTheDocument(); + expect(new Set(bubbleIds()).size).toBe(55); // no duplicates across pages + }); + + it("has a Load older button for keyboard users", async () => { + const user = userEvent.setup(); + await renderServerThread(); + await user.click(screen.getByRole("button", { name: "Load older messages" })); + await waitFor(() => expect(screen.getAllByTestId("message").length).toBe(40)); + }); +}); + +describe("MessageThread — incoming messages", () => { + function setup() { + const ctx = seed(Array.from({ length: 10 }, () => msg())); + // React Query notifies observers on a macrotask, so wait for the DOM to catch up. + const receive = async (message: ReturnType) => { + await act(async () => { + ctx.queryClient.setQueryData(messageKeys.thread("c1"), (d) => upsertMessage(d, message)); + }); + await waitFor(() => expect(document.querySelector(`[data-message-id="${message.id}"]`)).not.toBeNull()); + }; + return { ...ctx, receive }; + } + const atBottom = () => scroller().scrollHeight - VIEWPORT; + + it("does NOT yank the user down while reading history", async () => { + const { receive } = setup(); + const box = scroller(); + act(() => { + box.scrollTop = 2 * ROW; // reading older messages + fireEvent.scroll(box); + }); + + await receive(msg({ body: "new while reading" })); + + expect(box.scrollTop).toBe(2 * ROW); // unchanged + expect(screen.getByRole("button", { name: "1 new message" })).toBeInTheDocument(); + }); + + it("counts several unseen messages and the pill scrolls to the bottom", async () => { + const user = userEvent.setup(); + const { receive } = setup(); + const box = scroller(); + act(() => { + box.scrollTop = 0; + fireEvent.scroll(box); + }); + await receive(msg()); + await receive(msg()); + await user.click(screen.getByRole("button", { name: "2 new messages" })); + + expect(box.scrollTop).toBe(box.scrollHeight); + expect(screen.queryByRole("button", { name: /new message/ })).not.toBeInTheDocument(); + }); + + it("sticks to the bottom when the user was already near it", async () => { + const { receive } = setup(); + const box = scroller(); + act(() => { + box.scrollTop = atBottom() - 20; // within 80px of the bottom + fireEvent.scroll(box); + }); + + await receive(msg()); + expect(box.scrollTop).toBe(box.scrollHeight); + expect(screen.queryByRole("button", { name: /new message/ })).not.toBeInTheDocument(); + }); + + it("always scrolls to the bottom for the user's own message", async () => { + const { receive } = setup(); + const box = scroller(); + act(() => { + box.scrollTop = 0; + fireEvent.scroll(box); + }); + await receive(msg({ senderId: ME.id, senderName: "Me" })); + expect(box.scrollTop).toBe(box.scrollHeight); + }); + + it("renders a single bubble when the same message is delivered twice", async () => { + const { receive, queryClient } = setup(); + const dup = msg({ body: "once" }); + await receive(dup); + await receive({ ...dup }); + expect(screen.getAllByText("once")).toHaveLength(1); + expect(flattenMessages(queryClient.getQueryData(messageKeys.thread("c1")))).toHaveLength(11); + }); +}); diff --git a/src/features/messaging/__tests__/fakeLayout.ts b/src/features/messaging/__tests__/fakeLayout.ts new file mode 100644 index 00000000..c19a88f7 --- /dev/null +++ b/src/features/messaging/__tests__/fakeLayout.ts @@ -0,0 +1,61 @@ +/** + * jsdom has no layout engine. This installs a deterministic fake so scroll + * behaviour can be asserted: every child of an element is ROW px tall, + * `scrollHeight` is the sum, `clientHeight` is VIEWPORT, and `scrollTop` is a + * plain settable number. + */ +export const ROW = 100; +export const VIEWPORT = 300; + +const scrollTops = new WeakMap(); + +export function installFakeLayout(): () => void { + const proto = HTMLElement.prototype; + const originals = { + offsetTop: Object.getOwnPropertyDescriptor(proto, "offsetTop"), + scrollHeight: Object.getOwnPropertyDescriptor(Element.prototype, "scrollHeight"), + clientHeight: Object.getOwnPropertyDescriptor(Element.prototype, "clientHeight"), + scrollTop: Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop"), + }; + + Object.defineProperty(proto, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + return this.parentElement ? Array.from(this.parentElement.children).indexOf(this) * ROW : 0; + }, + }); + Object.defineProperty(Element.prototype, "scrollHeight", { + configurable: true, + get(this: Element) { + return this.getAttribute("role") === "log" ? this.children.length * ROW : 0; + }, + }); + Object.defineProperty(Element.prototype, "clientHeight", { + configurable: true, + get(this: Element) { + return this.getAttribute("role") === "log" ? VIEWPORT : 0; + }, + }); + Object.defineProperty(Element.prototype, "scrollTop", { + configurable: true, + get(this: Element) { + return scrollTops.get(this) ?? 0; + }, + set(this: Element, value: number) { + scrollTops.set(this, value); + }, + }); + + return () => { + for (const [name, target] of [ + ["offsetTop", proto], + ["scrollHeight", Element.prototype], + ["clientHeight", Element.prototype], + ["scrollTop", Element.prototype], + ] as const) { + const original = originals[name]; + if (original) Object.defineProperty(target, name, original); + else delete (target as unknown as Record)[name]; + } + }; +} diff --git a/src/features/messaging/__tests__/messageCache.test.ts b/src/features/messaging/__tests__/messageCache.test.ts new file mode 100644 index 00000000..c8fac642 --- /dev/null +++ b/src/features/messaging/__tests__/messageCache.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + applyMessageToConversations, + decrementUnread, + flattenMessages, + markMessagesRead, + removeMessage, + unreadAmong, + upsertMessage, + type MessagesCache, +} from "../messageCache"; +import type { Conversation } from "../types/message"; +import { msg } from "./testUtils"; + +function cache(...pages: ReturnType[][]): MessagesCache { + return { pages: pages.map((data) => ({ data })), pageParams: pages.map(() => undefined) }; +} + +describe("messageCache", () => { + it("flattens pages chronologically (older pages first)", () => { + const [a, b, c, d] = [msg(), msg(), msg(), msg()]; + // page 0 = newest page, page 1 = older + expect(flattenMessages(cache([c, d], [a, b])).map((m) => m.id)).toEqual([a.id, b.id, c.id, d.id]); + }); + + it("de-duplicates by id when flattening overlapping pages", () => { + const [a, b] = [msg(), msg()]; + expect(flattenMessages(cache([b], [a, b]))).toHaveLength(2); + }); + + it("appends a new message to the newest page", () => { + const a = msg(); + const b = msg(); + const next = upsertMessage(cache([a]), b)!; + expect(next.pages[0].data.map((m) => m.id)).toEqual([a.id, b.id]); + }); + + it("dedupes an incoming message that already exists (same id)", () => { + const a = msg(); + const next = upsertMessage(cache([a]), { ...a })!; + expect(flattenMessages(next)).toHaveLength(1); + }); + + it("replaces an optimistic temp message by clientId", () => { + const temp = msg({ id: "tmp-1", clientId: "c-1", status: "sending" }); + const server = msg({ id: "real-1", clientId: "c-1" }); + const next = upsertMessage(cache([temp]), server)!; + const all = flattenMessages(next); + expect(all).toHaveLength(1); + expect(all[0].id).toBe("real-1"); + }); + + it("collapses temp + echo into one when the echo arrived first", () => { + const temp = msg({ id: "tmp-1", clientId: "c-1", status: "sending" }); + const echo = msg({ id: "real-1" }); // socket echo without clientId + const withEcho = upsertMessage(cache([temp]), echo)!; + expect(flattenMessages(withEcho)).toHaveLength(2); + // HTTP response arrives carrying both the real id and the clientId + const merged = upsertMessage(withEcho, { ...echo, clientId: "c-1", status: "sent" })!; + expect(flattenMessages(merged).map((m) => m.id)).toEqual(["real-1"]); + }); + + it("returns undefined/unchanged when the thread isn't cached", () => { + expect(upsertMessage(undefined, msg())).toBeUndefined(); + }); + + it("removes by id or clientId", () => { + const a = msg({ clientId: "c-9" }); + expect(flattenMessages(removeMessage(cache([a]), "c-9"))).toEqual([]); + }); + + it("marks messages read once and reports which were unread", () => { + const [a, b] = [msg(), msg({ readAt: "2026-01-01T00:00:00Z" })]; + const data = cache([a, b]); + expect(unreadAmong(data, [a.id, b.id])).toEqual([a.id]); + const next = markMessagesRead(data, [a.id, b.id], "2026-02-02T00:00:00Z")!; + const [ra, rb] = flattenMessages(next); + expect(ra.readAt).toBe("2026-02-02T00:00:00Z"); + expect(rb.readAt).toBe("2026-01-01T00:00:00Z"); // untouched + }); + + describe("conversation list", () => { + const list: Conversation[] = [ + { id: "c1", title: "One", unreadCount: 1, updatedAt: "2026-01-01T00:00:00Z" }, + { id: "c2", title: "Two", unreadCount: 0, updatedAt: "2026-01-02T00:00:00Z" }, + ]; + + it("bumps unread, sets preview and re-sorts", () => { + const m = msg({ conversationId: "c1", createdAt: "2026-01-05T00:00:00Z" }); + const next = applyMessageToConversations(list, m, true)!; + expect(next[0].id).toBe("c1"); + expect(next[0].unreadCount).toBe(2); + expect(next[0].lastMessage?.id).toBe(m.id); + }); + + it("does not bump when told not to", () => { + const next = applyMessageToConversations(list, msg({ conversationId: "c1" }), false)!; + expect(next.find((c) => c.id === "c1")!.unreadCount).toBe(1); + }); + + it("decrements without going below zero", () => { + expect(decrementUnread(list, "c1", 5)!.find((c) => c.id === "c1")!.unreadCount).toBe(0); + }); + }); +}); diff --git a/src/features/messaging/__tests__/readReceipts.test.tsx b/src/features/messaging/__tests__/readReceipts.test.tsx new file mode 100644 index 00000000..669fd28b --- /dev/null +++ b/src/features/messaging/__tests__/readReceipts.test.tsx @@ -0,0 +1,149 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MessageThread } from "../components/MessageThread"; +import { messageKeys } from "../hooks/useMessages"; +import { READ_DELAY_MS } from "../hooks/useMarkMessagesRead"; +import { clearOutbox } from "../hooks/useSendMessage"; +import { flattenMessages, type MessagesCache } from "../messageCache"; +import type { Conversation } from "../types/message"; +import { createWrapper, msg, signIn } from "./testUtils"; + +/** Minimal IntersectionObserver double: tests decide when elements are visible. */ +class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + observed = new Set(); + private callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + FakeIntersectionObserver.instances.push(this); + } + observe = (el: Element) => void this.observed.add(el); + unobserve = (el: Element) => void this.observed.delete(el); + disconnect = () => this.observed.clear(); + takeRecords = () => []; + trigger(el: Element, isIntersecting: boolean, ratio = isIntersecting ? 1 : 0) { + this.callback( + [{ target: el, isIntersecting, intersectionRatio: ratio } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +function setVisibility(state: "visible" | "hidden", focused = state === "visible") { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + vi.spyOn(document, "hasFocus").mockReturnValue(focused); + document.dispatchEvent(new Event("visibilitychange")); +} + +function renderThread() { + const ctx = createWrapper(); + const incoming = msg({ id: "in-1", conversationId: "c1" }); + ctx.queryClient.setQueryData(messageKeys.thread("c1"), { + pages: [{ data: [incoming] }], + pageParams: [undefined], + }); + ctx.queryClient.setQueryData(messageKeys.conversations, [ + { id: "c1", title: "One", unreadCount: 1, updatedAt: "2026-01-01T00:00:00.000Z" }, + ]); + render(, { wrapper: ctx.wrapper }); + + const el = screen.getByTestId("message"); + const io = FakeIntersectionObserver.instances.at(-1)!; + const readAt = () => flattenMessages(ctx.queryClient.getQueryData(messageKeys.thread("c1")))[0].readAt; + const unread = () => ctx.queryClient.getQueryData(messageKeys.conversations)![0].unreadCount; + return { ...ctx, el, io, readAt, unread }; +} + +describe("read receipts", () => { + beforeEach(() => { + signIn(); + clearOutbox(); + FakeIntersectionObserver.instances = []; + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.useFakeTimers(); + setVisibility("visible"); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + // @ts-expect-error restore jsdom default + delete document.visibilityState; + }); + + it("marks a message read after it has been visible for ~1s", () => { + const { el, io, readAt, unread } = renderThread(); + act(() => io.trigger(el, true)); + + act(() => void vi.advanceTimersByTime(READ_DELAY_MS - 1)); + expect(readAt()).toBeNull(); + + act(() => void vi.advanceTimersByTime(1)); + expect(readAt()).toBeTruthy(); + expect(unread()).toBe(0); + }); + + it("does NOT mark read while the tab is hidden", () => { + setVisibility("hidden"); + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true)); + act(() => void vi.advanceTimersByTime(10_000)); + expect(readAt()).toBeNull(); + }); + + it("does NOT mark read while the window is unfocused", () => { + setVisibility("visible", false); + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true)); + act(() => void vi.advanceTimersByTime(10_000)); + expect(readAt()).toBeNull(); + }); + + it("starts counting once the hidden tab becomes active again", () => { + setVisibility("hidden"); + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true)); + act(() => void vi.advanceTimersByTime(5_000)); + expect(readAt()).toBeNull(); + + act(() => setVisibility("visible")); + act(() => void vi.advanceTimersByTime(READ_DELAY_MS)); + expect(readAt()).toBeTruthy(); + }); + + it("cancels if the tab is hidden mid-delay", () => { + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true)); + act(() => void vi.advanceTimersByTime(500)); + + act(() => setVisibility("hidden")); + act(() => void vi.advanceTimersByTime(5_000)); + expect(readAt()).toBeNull(); + }); + + it("cancels if the message scrolls out of view before the delay", () => { + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true)); + act(() => void vi.advanceTimersByTime(600)); + act(() => io.trigger(el, false)); + act(() => void vi.advanceTimersByTime(5_000)); + expect(readAt()).toBeNull(); + }); + + it("ignores messages that are only barely in view", () => { + const { el, io, readAt } = renderThread(); + act(() => io.trigger(el, true, 0.2)); + act(() => void vi.advanceTimersByTime(5_000)); + expect(readAt()).toBeNull(); + }); + + it("only observes unread incoming messages", () => { + const ctx = createWrapper(); + ctx.queryClient.setQueryData(messageKeys.thread("c1"), { + pages: [{ data: [msg({ readAt: "2026-01-01T00:00:00Z" }), msg({ senderId: "user-me" })] }], + pageParams: [undefined], + }); + render(, { wrapper: ctx.wrapper }); + expect(FakeIntersectionObserver.instances.at(-1)!.observed.size).toBe(0); + }); +}); diff --git a/src/features/messaging/__tests__/testUtils.tsx b/src/features/messaging/__tests__/testUtils.tsx new file mode 100644 index 00000000..89291240 --- /dev/null +++ b/src/features/messaging/__tests__/testUtils.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { Message } from "../types/message"; + +export const ME = { id: "user-me", fullName: "Me Myself" }; + +export function signIn(): void { + localStorage.setItem("petad_user", JSON.stringify(ME)); +} + +export function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return { queryClient, wrapper }; +} + +let counter = 0; +export function msg(overrides: Partial = {}): Message { + counter += 1; + return { + id: `m${counter}`, + conversationId: "c1", + senderId: "user-other", + senderName: "Jane", + body: `body ${counter}`, + attachments: [], + createdAt: new Date(2026, 0, 10, 9, counter).toISOString(), + readAt: null, + ...overrides, + }; +} + +export function file(name: string, type: string, size = 1024): File { + return new File([new Uint8Array(size)], name, { type }); +} diff --git a/src/features/messaging/__tests__/uploadRules.test.ts b/src/features/messaging/__tests__/uploadRules.test.ts new file mode 100644 index 00000000..219d3766 --- /dev/null +++ b/src/features/messaging/__tests__/uploadRules.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { acceptAttribute, UPLOAD_RULES, validateFiles } from "../uploadRules"; +import { file } from "./testUtils"; + +describe("uploadRules (message kind)", () => { + it("accepts images and PDFs", () => { + const { accepted, errors } = validateFiles("message", [], [ + file("a.png", "image/png"), + file("b.jpg", "image/jpeg"), + file("c.pdf", "application/pdf"), + ]); + expect(accepted).toHaveLength(3); + expect(errors).toEqual([]); + }); + + it("rejects wrong types with specific copy naming the file and type", () => { + const { accepted, errors } = validateFiles("message", [], [ + file("notes.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + ]); + expect(accepted).toEqual([]); + expect(errors).toEqual([ + '"notes.docx" is a .docx file. Only images (JPG, PNG, WebP, GIF) and PDFs can be attached.', + ]); + }); + + it("rejects oversized files with the size and the limit", () => { + const { errors } = validateFiles("message", [], [file("big.png", "image/png", 7.2 * 1024 * 1024)]); + expect(errors).toEqual(['"big.png" is 7.2 MB. Each attachment must be 5.0 MB or smaller.']); + }); + + it("rejects empty files", () => { + expect(validateFiles("message", [], [file("empty.pdf", "application/pdf", 0)]).errors[0]).toMatch(/is empty/); + }); + + it("caps at 3 files per message, counting files already queued", () => { + const existing = [file("1.png", "image/png"), file("2.png", "image/png")]; + const { accepted, errors } = validateFiles("message", existing, [ + file("3.png", "image/png"), + file("4.png", "image/png"), + ]); + expect(accepted.map((f) => f.name)).toEqual(["3.png"]); + expect(errors).toEqual(['You can attach up to 3 files per message. "4.png" was not added.']); + }); + + it("falls back to the extension when the browser reports no MIME type", () => { + expect(validateFiles("message", [], [file("scan.pdf", "")]).accepted).toHaveLength(1); + expect(validateFiles("message", [], [file("scan.exe", "")]).accepted).toHaveLength(0); + }); + + it("builds an accept attribute from the rule", () => { + expect(acceptAttribute("message")).toContain("application/pdf"); + expect(acceptAttribute("message")).toContain(".png"); + expect(UPLOAD_RULES.message.maxFiles).toBe(3); + }); +}); diff --git a/src/features/messaging/__tests__/useMessageSocketSync.test.tsx b/src/features/messaging/__tests__/useMessageSocketSync.test.tsx new file mode 100644 index 00000000..ce6ba7c2 --- /dev/null +++ b/src/features/messaging/__tests__/useMessageSocketSync.test.tsx @@ -0,0 +1,183 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MockWebSocket } from "../../../test/mockWebSocket"; +import { messageKeys } from "../hooks/useMessages"; +import { useMessageSocketSync } from "../hooks/useMessageSocketSync"; +import { flattenMessages, type MessagesCache } from "../messageCache"; +import type { Conversation } from "../types/message"; +import { createWrapper, ME, msg, signIn } from "./testUtils"; + +const URL = "ws://test/api/ws/messages?token=t"; + +function setup(activeConversationId?: string) { + const { queryClient, wrapper } = createWrapper(); + for (const id of ["c1", "c2"]) { + queryClient.setQueryData(messageKeys.thread(id), { + pages: [{ data: [msg({ conversationId: id })] }], + pageParams: [undefined], + }); + } + queryClient.setQueryData(messageKeys.conversations, [ + { id: "c1", title: "One", unreadCount: 0, updatedAt: "2026-01-01T00:00:00.000Z" }, + { id: "c2", title: "Two", unreadCount: 0, updatedAt: "2026-01-01T00:00:00.000Z" }, + ]); + + // Reconnect timers are driven by hand so the test controls "time". + const timers: Array<() => void> = []; + const hook = renderHook( + () => + useMessageSocketSync({ + activeConversationId, + url: URL, + socketOptions: { + WebSocketImpl: MockWebSocket, + reconnectOnFocus: false, + setTimeoutFn: (handler) => { + timers.push(handler); + return 0 as unknown as ReturnType; + }, + }, + }), + { wrapper }, + ); + + const socket = MockWebSocket.latest(); + act(() => socket.open()); + + const push = (type: string, payload: unknown) => act(() => socket.receive({ type, payload })); + const thread = (id: string) => flattenMessages(queryClient.getQueryData(messageKeys.thread(id))); + const unread = (id: string) => + queryClient.getQueryData(messageKeys.conversations)!.find((c) => c.id === id)!.unreadCount; + + return { ...hook, queryClient, socket, timers, push, thread, unread }; +} + +describe("useMessageSocketSync", () => { + beforeEach(() => { + signIn(); + MockWebSocket.reset(); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + }); + afterEach(() => vi.restoreAllMocks()); + + it("bumps the unread count of a thread that isn't open, without any refetch", async () => { + const { push, unread, thread, queryClient } = setup("c1"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + + push("MESSAGE_CREATED", msg({ conversationId: "c2", body: "psst" })); + push("MESSAGE_CREATED", msg({ conversationId: "c2", body: "again" })); + + expect(unread("c2")).toBe(2); + expect(unread("c1")).toBe(0); + expect(thread("c2").map((m) => m.body)).toContain("psst"); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("updates the conversation preview and moves it to the top", () => { + const { push, queryClient } = setup("c1"); + const incoming = msg({ conversationId: "c2", body: "latest", createdAt: "2026-06-01T00:00:00.000Z" }); + push("MESSAGE_CREATED", incoming); + + const list = queryClient.getQueryData(messageKeys.conversations)!; + expect(list[0].id).toBe("c2"); + expect(list[0].lastMessage?.body).toBe("latest"); + }); + + it("doesn't count a message as unread when its thread is open and the tab is active", () => { + const { push, unread, thread } = setup("c1"); + push("MESSAGE_CREATED", msg({ conversationId: "c1", body: "live" })); + expect(thread("c1").map((m) => m.body)).toContain("live"); + expect(unread("c1")).toBe(0); + }); + + it("does count it when the thread is open but the tab is hidden/unfocused", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const { push, unread } = setup("c1"); + push("MESSAGE_CREATED", msg({ conversationId: "c1" })); + expect(unread("c1")).toBe(1); + }); + + it("ignores the user's own messages for unread", () => { + const { push, unread } = setup("c1"); + push("MESSAGE_CREATED", msg({ conversationId: "c2", senderId: ME.id })); + expect(unread("c2")).toBe(0); + }); + + it("dedupes redelivered events by id (no duplicate bubble, no double count)", () => { + const { push, unread, thread } = setup("c1"); + const m = msg({ conversationId: "c2", body: "once" }); + push("MESSAGE_CREATED", m); + push("MESSAGE_CREATED", { ...m }); + + expect(thread("c2").filter((x) => x.body === "once")).toHaveLength(1); + expect(unread("c2")).toBe(1); + }); + + it("merges the echo of an optimistic send by clientId", () => { + const { push, thread, queryClient } = setup("c1"); + queryClient.setQueryData(messageKeys.thread("c1"), { + pages: [{ data: [msg({ id: "tmp-x", clientId: "x", status: "sending", senderId: ME.id, conversationId: "c1" })] }], + pageParams: [undefined], + }); + + push("MESSAGE_CREATED", msg({ id: "srv-1", clientId: "x", senderId: ME.id, conversationId: "c1" })); + expect(thread("c1").map((m) => m.id)).toEqual(["srv-1"]); + }); + + it("ignores malformed events", () => { + const { push, unread } = setup("c1"); + push("MESSAGE_CREATED", { nope: true }); + push("MESSAGE_CREATED", null); + expect(unread("c1")).toBe(0); + }); + + it("applies MESSAGES_READ to the cache and lowers the unread badge", () => { + const { push, queryClient, unread, thread } = setup("c1"); + const m = msg({ conversationId: "c2", id: "to-read" }); + push("MESSAGE_CREATED", m); + expect(unread("c2")).toBe(1); + + push("MESSAGES_READ", { conversationId: "c2", messageIds: ["to-read"], readAt: "2026-02-02T00:00:00.000Z" }); + expect(thread("c2").find((x) => x.id === "to-read")?.readAt).toBe("2026-02-02T00:00:00.000Z"); + expect(unread("c2")).toBe(0); + expect(queryClient.isFetching()).toBe(0); + }); + + describe("reconnect", () => { + it("does not backfill on the first connection", () => { + const { queryClient } = setup("c1"); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("triggers exactly one backfill refetch after a reconnect", async () => { + const { queryClient, socket, timers } = setup("c1"); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + + act(() => socket.close(1006)); // network drop + await waitFor(() => expect(timers.length).toBeGreaterThan(0)); + act(() => timers.at(-1)!()); // backoff elapsed → new socket + const second = MockWebSocket.latest(); + expect(second).not.toBe(socket); + act(() => second.open()); + + const keys = invalidate.mock.calls.map(([filters]) => JSON.stringify(filters?.queryKey)); + expect(keys).toEqual([JSON.stringify(messageKeys.all), JSON.stringify(messageKeys.conversations)]); + }); + }); + + it("tears the socket down on unmount", () => { + const { unmount, socket } = setup("c1"); + unmount(); + expect(socket.readyState).toBe(3); + }); + + it("stays disconnected with a null url", () => { + MockWebSocket.reset(); + const { wrapper } = createWrapper(); + renderHook(() => useMessageSocketSync({ url: null, socketOptions: { WebSocketImpl: MockWebSocket } }), { wrapper }); + expect(MockWebSocket.instances).toHaveLength(0); + }); +}); diff --git a/src/features/messaging/__tests__/useSendMessage.test.tsx b/src/features/messaging/__tests__/useSendMessage.test.tsx new file mode 100644 index 00000000..7c26ace6 --- /dev/null +++ b/src/features/messaging/__tests__/useSendMessage.test.tsx @@ -0,0 +1,146 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; +import { server } from "../../../mocks/server"; +import { resetMessagesMock } from "../../../mocks/handlers/messages"; +import { flattenMessages, type MessagesCache } from "../messageCache"; +import { clearOutbox, useSendMessage } from "../hooks/useSendMessage"; +import { messageKeys } from "../hooks/useMessages"; +import { createWrapper, file, msg, signIn } from "./testUtils"; + +function setup() { + const { queryClient, wrapper } = createWrapper(); + queryClient.setQueryData(messageKeys.thread("conv-1"), { + pages: [{ data: [msg({ conversationId: "conv-1" })] }], + pageParams: [undefined], + }); + const hook = renderHook(() => useSendMessage("conv-1"), { wrapper }); + const thread = () => flattenMessages(queryClient.getQueryData(messageKeys.thread("conv-1"))); + return { ...hook, queryClient, thread }; +} + +describe("useSendMessage", () => { + beforeEach(() => { + signIn(); + clearOutbox(); + resetMessagesMock(); + }); + + it("shows the message instantly as `sending`, then flips to `sent`", async () => { + const { result, thread } = setup(); + + act(() => { + result.current.send({ body: "hello" }); + }); + + // Instant: temp message present before the network resolves. + expect(thread().at(-1)).toMatchObject({ body: "hello", status: "sending", senderId: "user-me" }); + + await waitFor(() => expect(thread().at(-1)?.status).toBe("sent")); + expect(thread().filter((m) => m.body === "hello")).toHaveLength(1); + expect(thread().at(-1)!.id).not.toMatch(/^tmp-/); + }); + + it("flips to `failed` on error, and Retry succeeds without duplicating", async () => { + let attempts = 0; + const seenClientIds: string[] = []; + server.use( + http.post("*/api/conversations/:id/messages", async ({ request }) => { + const body = (await request.json()) as { clientId: string; body: string }; + seenClientIds.push(body.clientId); + attempts += 1; + if (attempts === 1) return HttpResponse.json({ message: "boom" }, { status: 500 }); + return HttpResponse.json( + msg({ id: "srv-1", conversationId: "conv-1", senderId: "user-me", body: body.body, clientId: body.clientId }), + { status: 201 }, + ); + }), + ); + const { result, thread } = setup(); + + let clientId = ""; + act(() => { + clientId = result.current.send({ body: "flaky" }); + }); + await waitFor(() => expect(thread().at(-1)?.status).toBe("failed")); + + act(() => result.current.retry(clientId)); + expect(thread().at(-1)?.status).toBe("sending"); // same bubble, not a second one + + await waitFor(() => expect(thread().at(-1)?.status).toBe("sent")); + expect(thread().filter((m) => m.body === "flaky")).toHaveLength(1); + expect(thread().at(-1)!.id).toBe("srv-1"); + // Both attempts used the same idempotency key. + expect(new Set(seenClientIds).size).toBe(1); + }); + + it("does not duplicate when the socket echo arrives before the HTTP response", async () => { + const { result, queryClient, thread } = setup(); + server.use( + http.post("*/api/conversations/:id/messages", async ({ request }) => { + const { clientId } = (await request.json()) as { clientId: string }; + // Echo lands in the cache first (socket), carrying the clientId. + queryClient.setQueryData(messageKeys.thread("conv-1"), (data) => ({ + ...data!, + pages: [ + { + data: [ + ...data!.pages[0].data.filter((m) => m.clientId !== clientId), + msg({ id: "srv-2", conversationId: "conv-1", senderId: "user-me", body: "raced", clientId }), + ], + }, + ], + })); + return HttpResponse.json( + msg({ id: "srv-2", conversationId: "conv-1", senderId: "user-me", body: "raced", clientId }), + { status: 201 }, + ); + }), + ); + + act(() => { + result.current.send({ body: "raced" }); + }); + await waitFor(() => expect(thread().at(-1)?.status).toBe("sent")); + expect(thread().filter((m) => m.body === "raced")).toHaveLength(1); + }); + + it("remove() discards a failed message", async () => { + server.use(http.post("*/api/conversations/:id/messages", () => HttpResponse.json({}, { status: 500 }))); + const { result, thread } = setup(); + + let clientId = ""; + act(() => { + clientId = result.current.send({ body: "nope" }); + }); + await waitFor(() => expect(thread().at(-1)?.status).toBe("failed")); + + act(() => result.current.remove(clientId)); + expect(thread().some((m) => m.body === "nope")).toBe(false); + // Retrying a discarded message is a no-op. + act(() => result.current.retry(clientId)); + expect(thread().some((m) => m.body === "nope")).toBe(false); + }); + + it("sends attachments as multipart and renders them optimistically", async () => { + let contentType = ""; + server.use( + // jsdom's File can't be re-parsed by undici, so assert on the header only. + http.post("*/api/conversations/:id/messages", ({ request }) => { + contentType = request.headers.get("Content-Type") ?? ""; + return HttpResponse.json(msg({ id: "srv-9", conversationId: "conv-1", senderId: "user-me", body: "see attached" }), { + status: 201, + }); + }), + ); + const { result, thread } = setup(); + act(() => { + result.current.send({ body: "see attached", files: [file("pic.png", "image/png"), file("doc.pdf", "application/pdf")] }); + }); + expect(thread().at(-1)!.attachments.map((a) => a.fileName)).toEqual(["pic.png", "doc.pdf"]); + + await waitFor(() => expect(thread().at(-1)?.status).toBe("sent")); + // Browser-set boundary: we must not force a JSON content type. + expect(contentType).toMatch(/^multipart\/form-data; boundary=/); + }); +}); diff --git a/src/features/messaging/api/messageService.ts b/src/features/messaging/api/messageService.ts new file mode 100644 index 00000000..f1496dbe --- /dev/null +++ b/src/features/messaging/api/messageService.ts @@ -0,0 +1,71 @@ +import { apiClient } from "../../../lib/api-client"; +import type { Conversation, Message, MessagesPage } from "../types/message"; + +export const MESSAGES_PAGE_SIZE = 20; + +export interface SendMessageInput { + body: string; + /** Idempotency key: retrying with the same key must not create a second message. */ + clientId: string; + files?: File[]; +} + +export interface AttachmentUrl { + url: string; + /** ISO timestamp after which `url` stops working. */ + expiresAt: string; +} + +/** + * Messaging API. + * + * Endpoints (see src/mocks/handlers/messages.ts for the mocked contract): + * GET /conversations + * GET /conversations/:id/messages?cursor=&limit= newest page first + * POST /conversations/:id/messages JSON, or multipart with `files` + * POST /conversations/:id/messages/read { messageIds } + * GET /messages/:messageId/attachments/:attachmentId/url + */ +export const messageService = { + listConversations(): Promise { + return apiClient.get("/conversations"); + }, + + getMessages( + conversationId: string, + cursor?: string, + limit: number = MESSAGES_PAGE_SIZE, + ): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + return apiClient.get( + `/conversations/${encodeURIComponent(conversationId)}/messages?${params.toString()}`, + ); + }, + + sendMessage(conversationId: string, input: SendMessageInput): Promise { + const path = `/conversations/${encodeURIComponent(conversationId)}/messages`; + + if (input.files && input.files.length > 0) { + const form = new FormData(); + form.append("body", input.body); + form.append("clientId", input.clientId); + input.files.forEach((file) => form.append("files", file)); + return apiClient.post(path, form); + } + return apiClient.post(path, { body: input.body, clientId: input.clientId }); + }, + + async markRead(conversationId: string, messageIds: string[]): Promise { + await apiClient.post(`/conversations/${encodeURIComponent(conversationId)}/messages/read`, { + messageIds, + }); + }, + + /** Short-lived download URL for a non-image attachment. Fetch it on click, never cache it. */ + getAttachmentUrl(messageId: string, attachmentId: string): Promise { + return apiClient.get( + `/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}/url`, + ); + }, +}; diff --git a/src/features/messaging/api/messagingService.ts b/src/features/messaging/api/messagingService.ts new file mode 100644 index 00000000..de31975a --- /dev/null +++ b/src/features/messaging/api/messagingService.ts @@ -0,0 +1,52 @@ +import { apiClient } from "../../lib/api-client"; +import type { Conversation, Message } from "../types"; + +/** + * messagingService + * + * Real-time messaging API calls via the api-client. + */ +export const messagingService = { + /** GET /api/conversations — conversations for the current user. */ + async listConversations(): Promise { + return apiClient.get("/conversations"); + }, + + /** GET /api/conversations/:id — single conversation with its messages. */ + async getConversation(conversationId: string): Promise { + return apiClient.get(`/conversations/${conversationId}`); + }, + + /** POST /api/conversations — start a conversation for a pet + owner. */ + async createConversation(petId: string, ownerId: string): Promise { + return apiClient.post("/conversations", { petId, ownerId }); + }, + + /** POST /api/conversations/:id/messages — send a message. */ + async sendMessage(conversationId: string, body: string): Promise { + return apiClient.post(`/conversations/${conversationId}/messages`, { + conversationId, + body, + }); + }, + + /** PATCH /api/conversations/:id/read — mark conversation as read. */ + async markRead(conversationId: string): Promise { + return apiClient.patch(`/conversations/${conversationId}/read`); + }, + + /** POST /api/users/block — block a conversation partner. */ + async blockUser(ownerId: string): Promise { + return apiClient.post("/users/block", { ownerId }); + }, + + /** POST /api/users/unblock — lift a previously issued block. */ + async unblockUser(ownerId: string): Promise { + return apiClient.post("/users/unblock", { ownerId }); + }, + + /** POST /api/reports — file a report against a conversation partner. */ + async report(payload: unknown): Promise<{ reportId?: string }> { + return apiClient.post<{ reportId?: string }>("/reports", payload); + }, +}; diff --git a/src/features/messaging/components/AttachmentChips.tsx b/src/features/messaging/components/AttachmentChips.tsx new file mode 100644 index 00000000..c8a96037 --- /dev/null +++ b/src/features/messaging/components/AttachmentChips.tsx @@ -0,0 +1,61 @@ +import { useEffect, useMemo } from "react"; +import { FileText, X } from "lucide-react"; +import { formatBytes, isImageFile } from "../uploadRules"; + +interface AttachmentChipsProps { + files: File[]; + onRemove: (index: number) => void; +} + +/** Thumbnail chips for files queued in the composer. */ +export function AttachmentChips({ files, onRemove }: AttachmentChipsProps) { + const previews = useMemo( + () => + files.map((file) => + isImageFile(file) && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : null, + ), + [files], + ); + + useEffect( + () => () => { + if (typeof URL.revokeObjectURL === "function") { + previews.forEach((url) => url && URL.revokeObjectURL(url)); + } + }, + [previews], + ); + + if (files.length === 0) return null; + + return ( +
    + {files.map((file, index) => ( +
  • + {previews[index] ? ( + + ) : ( + + + )} + + {file.name} + {formatBytes(file.size)} + + +
  • + ))} +
+ ); +} diff --git a/src/features/messaging/components/AttachmentPreview.tsx b/src/features/messaging/components/AttachmentPreview.tsx new file mode 100644 index 00000000..1067228d --- /dev/null +++ b/src/features/messaging/components/AttachmentPreview.tsx @@ -0,0 +1,45 @@ +import { useEffect } from "react"; +import { X } from "lucide-react"; + +interface AttachmentPreviewProps { + src: string; + alt: string; + onClose: () => void; +} + +/** Full-size image overlay. Esc or the backdrop closes it. */ +export function AttachmentPreview({ src, alt, onClose }: AttachmentPreviewProps) { + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
+ + {alt} event.stopPropagation()} + /> +
+ ); +} diff --git a/src/features/messaging/components/ConversationList.tsx b/src/features/messaging/components/ConversationList.tsx new file mode 100644 index 00000000..c2af5c18 --- /dev/null +++ b/src/features/messaging/components/ConversationList.tsx @@ -0,0 +1,59 @@ +import { NavLink } from "react-router-dom"; +import { formatDistanceToNowStrict, parseISO } from "date-fns"; +import { useConversations } from "../hooks/useMessages"; + +/** Conversations with live unread badges (patched in place by the socket). */ +export function ConversationList() { + const { data, isPending, isError, refetch } = useConversations(); + + if (isPending) return

Loading conversations…

; + if (isError) { + return ( +
+

Couldn't load conversations.

+ +
+ ); + } + if (data.length === 0) return

No conversations yet.

; + + return ( +
    + {data.map((conversation) => ( +
  • + + `flex items-center gap-3 px-4 py-3 hover:bg-gray-50 ${isActive ? "bg-gray-50" : ""}` + } + > + + + 0 ? "font-bold" : "font-medium"}`}> + {conversation.title} + + + + + {conversation.lastMessage?.body || (conversation.lastMessage?.attachments.length ? "Sent an attachment" : "")} + + + {conversation.unreadCount > 0 && ( + + {conversation.unreadCount > 99 ? "99+" : conversation.unreadCount} + + )} + +
  • + ))} +
+ ); +} diff --git a/src/features/messaging/components/ConversationMenu.tsx b/src/features/messaging/components/ConversationMenu.tsx new file mode 100644 index 00000000..2e6486b9 --- /dev/null +++ b/src/features/messaging/components/ConversationMenu.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { MoreHorizontal, Block, Flag } from "lucide-react"; +import { useBlockConversation } from "../hooks/useBlockConversation"; +import { ReportConversationDialog } from "./ReportConversationDialog"; +import type { Conversation } from "../types"; + +interface ConversationMenuProps { + conversation: Conversation; + currentUserId: string; + className?: string; +} + +/** + * ConversationMenu + * + * Overflow menu on a conversation thread offering block / report actions. + * + * - Block: persists locally + via API, hides the composer, offers unblock + * - Report: opens the report dialog (reason + optional details, Zod validated) + */ +export function ConversationMenu({ + conversation, + currentUserId, + className = "", +}: ConversationMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + const { isBlocked, block, unblock, isBlocking } = useBlockConversation(); + + const counterpartyId = conversation.ownerId; + const alreadyBlocked = isBlocked(counterpartyId); + + return ( +
+ + + {isOpen && ( +
+ {alreadyBlocked ? ( + + ) : ( + <> + + + + )} +
+ )} + + setReportOpen(false)} + conversation={conversation} + currentUserId={currentUserId} + /> +
+ ); +} diff --git a/src/features/messaging/components/MessageBubble.tsx b/src/features/messaging/components/MessageBubble.tsx new file mode 100644 index 00000000..ad0ff89a --- /dev/null +++ b/src/features/messaging/components/MessageBubble.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { format, parseISO } from "date-fns"; +import { Download, FileText } from "lucide-react"; +import type { Message, MessageAttachment } from "../types/message"; +import { messageService } from "../api/messageService"; +import { formatBytes, isImageFile } from "../uploadRules"; +import { AttachmentPreview } from "./AttachmentPreview"; + +interface MessageBubbleProps { + message: Message; + isOwn: boolean; + /** Ref callback used to observe unread incoming messages. */ + observeRef?: (element: HTMLElement | null) => void | (() => void); + onRetry?: (clientId: string) => void; + onDelete?: (clientId: string) => void; +} + +function FileCard({ messageId, attachment }: { messageId: string; attachment: MessageAttachment }) { + const [error, setError] = useState(false); + const [busy, setBusy] = useState(false); + const pending = messageId.startsWith("tmp-"); + + const download = async () => { + setBusy(true); + setError(false); + try { + // Short-lived URL: fetched at click time, never stored. + const { url } = await messageService.getAttachmentUrl(messageId, attachment.id); + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + setError(true); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ ); +} + +export function MessageBubble({ message, isOwn, observeRef, onRetry, onDelete }: MessageBubbleProps) { + const [previewing, setPreviewing] = useState(null); + const failed = message.status === "failed"; + const sending = message.status === "sending"; + const sender = isOwn ? "you" : message.senderName; + const clientId = message.clientId ?? message.id; + + return ( +
+ {!isOwn && {message.senderName}} + +
+ {message.attachments.map((attachment) => + isImageFile({ type: attachment.mimeType, name: attachment.fileName }) && attachment.url ? ( + + ) : ( + + ), + )} + {message.body &&

{message.body}

} +
+ +
+ {sending ? ( + Sending… + ) : failed ? ( + <> + + Failed to send + + + + + ) : ( + + )} + {isOwn && !sending && !failed && message.readAt && Read} +
+ + {previewing && ( + setPreviewing(null)} /> + )} +
+ ); +} diff --git a/src/features/messaging/components/MessageComposer.tsx b/src/features/messaging/components/MessageComposer.tsx new file mode 100644 index 00000000..d1fa44d3 --- /dev/null +++ b/src/features/messaging/components/MessageComposer.tsx @@ -0,0 +1,126 @@ +import { useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; +import { Paperclip, Send } from "lucide-react"; +import { useSendMessage } from "../hooks/useSendMessage"; +import { clearDraft, getDraft, setDraft } from "../draftStore"; +import { acceptAttribute, UPLOAD_RULES, validateFiles } from "../uploadRules"; +import { AttachmentChips } from "./AttachmentChips"; + +interface MessageComposerProps { + conversationId: string; + disabled?: boolean; +} + +/** + * Composer for one conversation. Enter sends, Shift+Enter inserts a newline. + * Drafts (text and queued attachments) are kept in memory per conversation, so + * switching threads and coming back restores them. + */ +export function MessageComposer({ conversationId, disabled }: MessageComposerProps) { + // Remount per conversation so state initialises from that conversation's draft. + return ; +} + +function ComposerInner({ conversationId, disabled }: MessageComposerProps) { + const initial = getDraft(conversationId); + const [text, setText] = useState(initial.text); + const [files, setFiles] = useState(initial.files); + const [errors, setErrors] = useState([]); + const fileInput = useRef(null); + const { send } = useSendMessage(conversationId); + + const canSend = !disabled && (text.trim().length > 0 || files.length > 0); + const maxFiles = UPLOAD_RULES.message.maxFiles; + + const update = (nextText: string, nextFiles: File[]) => { + setText(nextText); + setFiles(nextFiles); + setDraft(conversationId, { text: nextText, files: nextFiles }); + }; + + const submit = () => { + if (!canSend) return; + send({ body: text.trim(), files: files.length > 0 ? files : undefined }); + setText(""); + setFiles([]); + setErrors([]); + clearDraft(conversationId); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + submit(); + }; + + const handleFiles = (event: ChangeEvent) => { + const incoming = Array.from(event.target.files ?? []); + event.target.value = ""; + if (incoming.length === 0) return; + + const { accepted, errors: problems } = validateFiles("message", files, incoming); + setErrors(problems); + if (accepted.length > 0) update(text, [...files, ...accepted]); + }; + + return ( +
{ + event.preventDefault(); + submit(); + }} + > + update(text, files.filter((_, i) => i !== index))} /> + + {errors.length > 0 && ( +
    + {errors.map((message) => ( +
  • {message}
  • + ))} +
+ )} + +
+ + + +