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 (
+
+
+

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 (
+
+
+
+ {attachment.fileName}
+ {formatBytes(attachment.size)}
+ {error && (
+
+ Couldn't get the download link. Try again.
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/features/messaging/components/MessageOwnerButton.tsx b/src/features/messaging/components/MessageOwnerButton.tsx
new file mode 100644
index 00000000..4b4169b5
--- /dev/null
+++ b/src/features/messaging/components/MessageOwnerButton.tsx
@@ -0,0 +1,84 @@
+import { useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { MessageCircle } from "lucide-react";
+import { useStartConversation } from "../hooks/useStartConversation";
+import { useCurrentUser } from "../hooks/useCurrentUser";
+
+interface MessageOwnerButtonProps {
+ petId: string;
+ ownerId: string;
+ className?: string;
+}
+
+/**
+ * MessageOwnerButton
+ *
+ * "Message owner" button for pet and custody detail pages.
+ *
+ * - Reuses an existing conversation for the same pet + owner pair
+ * - De-duplicates rapid repeated clicks via `useStartConversation`
+ * - Hidden when the current user is the listing owner
+ * - Redirects logged-out users to /login with a return URL
+ */
+export function MessageOwnerButton({
+ petId,
+ ownerId,
+ className = "",
+}: MessageOwnerButtonProps) {
+ const navigate = useNavigate();
+ const { user } = useCurrentUser();
+ const { startConversation, isPending } = useStartConversation();
+
+ const handleLoginRedirect = useCallback(() => {
+ navigate(
+ `/login?returnTo=${encodeURIComponent(window.location.pathname)}`,
+ { replace: true },
+ );
+ }, [navigate]);
+
+ const handleStartConversation = useCallback(() => {
+ void startConversation({ petId, ownerId });
+ }, [startConversation, petId, ownerId]);
+
+ // Not logged in — redirect to login with a return URL so the user can
+ // resume the conversation flow after signing in.
+ if (!user) {
+ return (
+
+ );
+ }
+
+ // Your own listing — hide the button.
+ if (user.id === ownerId) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/messaging/components/MessageThread.tsx b/src/features/messaging/components/MessageThread.tsx
new file mode 100644
index 00000000..6d598ebd
--- /dev/null
+++ b/src/features/messaging/components/MessageThread.tsx
@@ -0,0 +1,223 @@
+import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState, type UIEvent } from "react";
+import { format, isToday, isYesterday, parseISO } from "date-fns";
+import { ArrowDown } from "lucide-react";
+import { useMessagingUser } from "../hooks/useMessagingUser";
+import { flattenMessages } from "../messageCache";
+import { useMarkMessagesRead } from "../hooks/useMarkMessagesRead";
+import { useMessages } from "../hooks/useMessages";
+import { useSendMessage } from "../hooks/useSendMessage";
+import type { Message } from "../types/message";
+import { MessageBubble } from "./MessageBubble";
+
+/** Within this many px of the bottom counts as "reading the latest". */
+const NEAR_BOTTOM_PX = 80;
+/** Scrolling within this many px of the top loads older messages. */
+const LOAD_OLDER_PX = 60;
+
+interface MessageThreadProps {
+ conversationId: string;
+ /** Sizing/layout for the scroll container; it must have a bounded height. */
+ className?: string;
+}
+
+function dayKey(message: Message): string {
+ return format(parseISO(message.createdAt), "yyyy-MM-dd");
+}
+
+function dayLabel(message: Message): string {
+ const date = parseISO(message.createdAt);
+ if (isToday(date)) return "Today";
+ if (isYesterday(date)) return "Yesterday";
+ return format(date, "EEEE, d MMM yyyy");
+}
+
+interface Anchor {
+ /** Message that was first (topmost) before older pages were prepended. */
+ messageId: string;
+ /** Its distance from the container's visible top edge. */
+ offset: number;
+}
+
+/**
+ * Chat thread. Newest message at the bottom, older ones load as you scroll up.
+ *
+ * - Loading older messages keeps the viewport still: we remember which message
+ * was at the top (and how far from the edge) and re-pin it after the prepend.
+ * - New messages only pull the view to the bottom when you were already near
+ * it (or sent the message). Otherwise a "New messages" button appears.
+ * - Messages are grouped by day; bubbles align by `senderId` vs the current user.
+ */
+export function MessageThread({ conversationId, className = "h-full" }: MessageThreadProps) {
+ // Remount per conversation so scroll bookkeeping starts fresh.
+ return ;
+}
+
+function ThreadInner({ conversationId, className }: Required) {
+ const user = useMessagingUser();
+ const query = useMessages(conversationId);
+ const { retry, remove } = useSendMessage(conversationId);
+ const { observe } = useMarkMessagesRead(conversationId);
+ const { fetchNextPage, hasNextPage, isFetchingNextPage } = query;
+
+ const messages = useMemo(() => flattenMessages(query.data), [query.data]);
+
+ const containerRef = useRef(null);
+ const nearBottom = useRef(true);
+ const anchor = useRef(null);
+ const prev = useRef<{ first?: string; last?: string }>({});
+ const [unseen, setUnseen] = useState(0);
+
+ const scrollToBottom = useCallback(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ nearBottom.current = true;
+ setUnseen(0);
+ }, []);
+
+ const loadOlder = useCallback(() => {
+ const el = containerRef.current;
+ if (!el || !hasNextPage || isFetchingNextPage || messages.length === 0) return;
+
+ const first = el.querySelector(`[data-message-id="${CSS.escape(messages[0].id)}"]`);
+ if (first) anchor.current = { messageId: messages[0].id, offset: first.offsetTop - el.scrollTop };
+ void fetchNextPage();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage, messages]);
+
+ const handleScroll = (event: UIEvent) => {
+ const el = event.currentTarget;
+ nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
+ if (nearBottom.current) setUnseen(0);
+ if (el.scrollTop < LOAD_OLDER_PX) loadOlder();
+ };
+
+ // Runs after every change to the message list, before paint.
+ useLayoutEffect(() => {
+ const el = containerRef.current;
+ if (!el || messages.length === 0) return;
+
+ const first = messages[0].id;
+ const last = messages[messages.length - 1];
+ const before = prev.current;
+ prev.current = { first, last: last.id };
+
+ if (before.last === undefined) {
+ scrollToBottom(); // first paint of this thread
+ return;
+ }
+
+ if (anchor.current && first !== anchor.current.messageId) {
+ // Older messages were prepended: re-pin the previous top message.
+ const target = el.querySelector(`[data-message-id="${CSS.escape(anchor.current.messageId)}"]`);
+ if (target) el.scrollTop = target.offsetTop - anchor.current.offset;
+ anchor.current = null;
+ return;
+ }
+
+ if (last.id !== before.last) {
+ const mine = last.senderId === user?.id;
+ if (mine || nearBottom.current) scrollToBottom();
+ else setUnseen((count) => count + 1);
+ }
+ }, [messages, scrollToBottom, user?.id]);
+
+ // A page shorter than the viewport can't be scrolled, so keep loading.
+ useLayoutEffect(() => {
+ const el = containerRef.current;
+ if (!el || el.clientHeight === 0) return;
+ if (el.scrollHeight <= el.clientHeight) loadOlder();
+ }, [messages, loadOlder]);
+
+ // The anchor is only meaningful for the fetch that created it.
+ useLayoutEffect(() => {
+ if (!isFetchingNextPage) anchor.current = null;
+ }, [isFetchingNextPage]);
+
+ if (query.isPending) {
+ return (
+
+ Loading messages…
+
+ );
+ }
+
+ if (query.isError) {
+ return (
+
+
Couldn't load messages.
+
+
+ );
+ }
+
+ let lastDay = "";
+
+ return (
+
+
+ {hasNextPage && (
+
+
+
+ )}
+
+ {messages.length === 0 &&
No messages yet. Say hello!
}
+
+ {messages.map((message) => {
+ const key = dayKey(message);
+ const showDay = key !== lastDay;
+ lastDay = key;
+ const isOwn = message.senderId === user?.id;
+ const unreadIncoming = !isOwn && !message.readAt && message.status !== "sending";
+
+ return (
+
+ {showDay && (
+
+
+ {dayLabel(message)}
+
+
+ )}
+
+
+ );
+ })}
+
+
+ {unseen > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/features/messaging/components/MessagesLink.tsx b/src/features/messaging/components/MessagesLink.tsx
new file mode 100644
index 00000000..3de5582a
--- /dev/null
+++ b/src/features/messaging/components/MessagesLink.tsx
@@ -0,0 +1,52 @@
+import { Link, useLocation } from "react-router-dom";
+import { MessageCircle } from "lucide-react";
+import { useUnreadMessageCount } from "../hooks/useUnreadMessageCount";
+
+/** Maximum unread badge number before the UI switches to the `99+` cap. */
+export const MESSAGES_UNREAD_BADGE_CAP = 99;
+
+/**
+ * MessagesLink
+ *
+ * Navigation link to the conversations inbox with a live unread-message badge.
+ *
+ * - Badge is capped at `99+`
+ * - Accessible label reads `Messages, N unread`
+ * - Count is derived from the conversation cache, so it updates when messages
+ * arrive or conversations are marked read
+ */
+export function MessagesLink() {
+ const location = useLocation();
+ const unreadCount = useUnreadMessageCount();
+ const displayCount = Math.min(unreadCount, MESSAGES_UNREAD_BADGE_CAP);
+ const isActive = location.pathname === "/messages" ||
+ location.pathname.startsWith("/messages/");
+
+ const ariaLabel =
+ unreadCount === 0
+ ? "Messages"
+ : `Messages, ${unreadCount} unread`;
+
+ return (
+
+
+ {unreadCount > 0 ? (
+
+ {displayCount >= MESSAGES_UNREAD_BADGE_CAP ? "99+" : displayCount}
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/messaging/components/ReportConversationDialog.tsx b/src/features/messaging/components/ReportConversationDialog.tsx
new file mode 100644
index 00000000..49f52de4
--- /dev/null
+++ b/src/features/messaging/components/ReportConversationDialog.tsx
@@ -0,0 +1,229 @@
+import { useState, useCallback, useId } from "react";
+import { Flag, CheckCircle } from "lucide-react";
+import {
+ reportConversationSchema,
+ REPORT_REASONS,
+ REPORT_REASON_LABELS,
+ type ReportConversationInput,
+} from "../schemas/messagingSchemas";
+import { useReportConversation } from "../hooks/useReportConversation";
+import type { Conversation } from "../types";
+
+interface ReportConversationDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ conversation: Conversation;
+ currentUserId: string;
+}
+
+/**
+ * ReportConversationDialog
+ *
+ * Report a conversation partner with reason selection and optional details.
+ *
+ * - Validates with Zod (reason required, details capped at 1000 chars)
+ * - Confirms success without exposing the reported user's private data
+ * - Persists the report so it isn't re-submitted on refresh
+ */
+export function ReportConversationDialog({
+ isOpen,
+ onClose,
+ conversation,
+ currentUserId,
+}: ReportConversationDialogProps) {
+ const [step, setStep] = useState<"form" | "confirmation">("form");
+ const [formError, setFormError] = useState(null);
+ const [reason, setReason] = useState("");
+ const [details, setDetails] = useState("");
+ const titleId = useId();
+
+ const {
+ reportConversation,
+ isReporting,
+ error,
+ reportConfirmation,
+ clearConfirmation,
+ } = useReportConversation();
+
+ const handleClose = useCallback(() => {
+ setStep("form");
+ setFormError(null);
+ setReason("");
+ setDetails("");
+ clearConfirmation();
+ onClose();
+ }, [onClose, clearConfirmation]);
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setFormError(null);
+
+ const payload: ReportConversationInput = {
+ conversationId: conversation.id,
+ reporterId: currentUserId,
+ ownerId: conversation.ownerId,
+ reason,
+ details: details.trim() || undefined,
+ };
+
+ const parsed = reportConversationSchema.safeParse(payload);
+ if (!parsed.success) {
+ setFormError("Please select a reason before submitting.");
+ return;
+ }
+
+ try {
+ await new Promise((resolve, reject) => {
+ reportConversation(parsed.data);
+ const start = Date.now();
+ const poll = () => {
+ if (reportConfirmation) {
+ resolve();
+ } else if (error) {
+ reject(error);
+ } else if (Date.now() - start > 30_000) {
+ reject(new Error("Request timed out"));
+ } else {
+ setTimeout(poll, 50);
+ }
+ };
+ poll();
+ });
+ setStep("confirmation");
+ } catch {
+ setFormError("Failed to submit your report. Please try again.");
+ }
+ },
+ [conversation.id, conversation.ownerId, currentUserId, reason, details, reportConversation, error, reportConfirmation],
+ );
+
+ if (!isOpen) return null;
+
+ const isConfirmation = step === "confirmation";
+ const isFormValid = REPORT_REASONS.includes(reason as ReportConversationInput["reason"]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+ {isConfirmation ? (
+
+
+
+
+
+ Report submitted
+
+
+ {reportConfirmation ?? "Our team will review your report."}
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/features/messaging/draftStore.ts b/src/features/messaging/draftStore.ts
new file mode 100644
index 00000000..2c3fc0bb
--- /dev/null
+++ b/src/features/messaging/draftStore.ts
@@ -0,0 +1,32 @@
+/**
+ * In-memory composer drafts, keyed by conversation. Module-level on purpose:
+ * drafts survive switching threads (the composer remounts) but not a reload,
+ * so message text never touches persistent storage.
+ */
+export interface Draft {
+ text: string;
+ files: File[];
+}
+
+const drafts = new Map();
+
+export function getDraft(conversationId: string): Draft {
+ return drafts.get(conversationId) ?? { text: "", files: [] };
+}
+
+export function setDraft(conversationId: string, draft: Draft): void {
+ if (draft.text === "" && draft.files.length === 0) {
+ drafts.delete(conversationId);
+ return;
+ }
+ drafts.set(conversationId, draft);
+}
+
+export function clearDraft(conversationId: string): void {
+ drafts.delete(conversationId);
+}
+
+/** Test helper. */
+export function clearAllDrafts(): void {
+ drafts.clear();
+}
diff --git a/src/features/messaging/hooks/__tests__/useLiveMessages.test.tsx b/src/features/messaging/hooks/__tests__/useLiveMessages.test.tsx
new file mode 100644
index 00000000..a68225a6
--- /dev/null
+++ b/src/features/messaging/hooks/__tests__/useLiveMessages.test.tsx
@@ -0,0 +1,288 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MockWebSocket } from "../../../../test/mockWebSocket";
+import { QueryClient } from "@tanstack/react-query";
+import type { Conversation } from "../../types";
+
+describe("useLiveMessages (manual cache tests)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ MockWebSocket.reset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("merges live message into conversation thread without duplicates", () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ const conversationId = "conv-1";
+ const currentUserId = "user-current";
+
+ // Set up initial conversation in cache
+ const initialConversation: Conversation = {
+ id: conversationId,
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: [currentUserId, "user-owner"],
+ messages: [],
+ lastMessage: null,
+ unreadCount: 0,
+ updatedAt: new Date().toISOString(),
+ };
+
+ queryClient.setQueryData([ "conversations", conversationId ], initialConversation);
+
+ // Simulate the mergeLiveMessage logic from useLiveMessages
+ const detailKey = ["conversations", conversationId] as const;
+
+ // Mock live message from counterparty
+ const liveMessage = {
+ type: "new_message",
+ conversationId,
+ message: {
+ id: "msg-1",
+ conversationId,
+ senderId: "user-other",
+ recipientId: currentUserId,
+ body: "Hello live!",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ },
+ } as const;
+
+ // 1. Append to the conversation thread
+ queryClient.setQueryData(detailKey, (old) => {
+ if (!old) return old;
+ const alreadyPresent = old.messages?.some(
+ (m) => m.id === liveMessage.message.id,
+ );
+ if (alreadyPresent) return old;
+ return {
+ ...old,
+ messages: [...(old.messages ?? []), liveMessage.message],
+ lastMessage: liveMessage.message,
+ updatedAt: liveMessage.message.createdAt,
+ };
+ });
+
+ // 2. Rewrite the conversation list entry (increment unread for counterparty messages)
+ queryClient.setQueryData(
+ ["conversations"],
+ (list) => {
+ if (!list) return list;
+ return list.map((c) => {
+ if (c.id !== conversationId) return c;
+ const isFromCurrentUser = liveMessage.message.senderId === currentUserId;
+ return {
+ ...c,
+ lastMessage: liveMessage.message,
+ updatedAt: liveMessage.message.createdAt,
+ // Increment unread only when the message is from the counterparty.
+ unreadCount: isFromCurrentUser
+ ? c.unreadCount
+ : (c.unreadCount ?? 0) + 1,
+ };
+ });
+ },
+ );
+
+ // Also update the conversation detail's unreadCount to match
+ queryClient.setQueryData(detailKey, (old) => {
+ if (!old) return old;
+ const isFromCurrentUser = liveMessage.message.senderId === currentUserId;
+ return {
+ ...old,
+ lastMessage: liveMessage.message,
+ updatedAt: liveMessage.message.createdAt,
+ unreadCount: isFromCurrentUser ? old.unreadCount : (old.unreadCount ?? 0) + 1,
+ };
+ });
+
+ // Verify the message was merged into the cache
+ const thread = queryClient.getQueryData([
+ "conversations",
+ conversationId,
+ ]);
+
+ expect(thread?.messages).toBeDefined();
+ expect(thread?.messages?.length).toBe(1);
+ expect(thread?.messages?.[0].body).toBe("Hello live!");
+ expect(thread?.lastMessage?.body).toBe("Hello live!");
+ expect(thread?.updatedAt).toBe(liveMessage.message.createdAt);
+ // Message is from counterparty, so unreadCount should be incremented
+ expect(thread?.unreadCount).toBe(1);
+ });
+
+ it("does not increment unreadCount for messages from current user", () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ const conversationId = "conv-1";
+ const currentUserId = "user-current";
+
+ // Set up initial conversation
+ const initialConversation: Conversation = {
+ id: conversationId,
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: [currentUserId, "user-owner"],
+ messages: [],
+ lastMessage: null,
+ unreadCount: 0,
+ updatedAt: new Date().toISOString(),
+ };
+
+ queryClient.setQueryData([ "conversations", conversationId ], initialConversation);
+
+ // Simulate the merge logic for a message from current user
+ const detailKey = ["conversations", conversationId] as const;
+
+ const liveMessage = {
+ type: "new_message",
+ conversationId,
+ message: {
+ id: "msg-1",
+ conversationId,
+ senderId: currentUserId, // current user
+ recipientId: "user-owner",
+ body: "Hi there!",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ },
+ } as const;
+
+ // 1. Append to the conversation thread
+ queryClient.setQueryData(detailKey, (old) => {
+ if (!old) return old;
+ const alreadyPresent = old.messages?.some(
+ (m) => m.id === liveMessage.message.id,
+ );
+ if (alreadyPresent) return old;
+ return {
+ ...old,
+ messages: [...(old.messages ?? []), liveMessage.message],
+ lastMessage: liveMessage.message,
+ updatedAt: liveMessage.message.createdAt,
+ };
+ });
+
+ // 2. Rewrite the conversation list entry (NO increment for current user messages)
+ queryClient.setQueryData(
+ ["conversations"],
+ (list) => {
+ if (!list) return list;
+ return list.map((c) => {
+ if (c.id !== conversationId) return c;
+ const isFromCurrentUser = liveMessage.message.senderId === currentUserId;
+ return {
+ ...c,
+ lastMessage: liveMessage.message,
+ updatedAt: liveMessage.message.createdAt,
+ unreadCount: isFromCurrentUser ? c.unreadCount : (c.unreadCount ?? 0) + 1,
+ };
+ });
+ },
+ );
+
+ const thread = queryClient.getQueryData([
+ "conversations",
+ conversationId,
+ ]);
+
+ expect(thread?.messages?.length).toBe(1);
+ expect(thread?.messages?.[0].body).toBe("Hi there!");
+ // unreadCount should stay 0 since message is from current user
+ expect(thread?.unreadCount).toBe(0);
+ });
+
+ it("does not merge duplicate messages by ID", () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ const conversationId = "conv-1";
+ const currentUserId = "user-current";
+
+ // Set up initial conversation with an existing message
+ const initialConversation: Conversation = {
+ id: conversationId,
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: [currentUserId, "user-owner"],
+ messages: [
+ {
+ id: "msg-0",
+ conversationId,
+ senderId: "user-other",
+ recipientId: currentUserId,
+ body: "First message",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ },
+ ],
+ lastMessage: {
+ id: "msg-0",
+ conversationId,
+ senderId: "user-other",
+ recipientId: currentUserId,
+ body: "First message",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ },
+ unreadCount: 1,
+ updatedAt: new Date().toISOString(),
+ };
+
+ queryClient.setQueryData([ "conversations", conversationId ], initialConversation);
+
+ // Simulate merging a message with the same ID
+ const detailKey = ["conversations", conversationId] as const;
+
+ const duplicateMessage = {
+ type: "new_message",
+ conversationId,
+ message: {
+ id: "msg-0", // duplicate ID
+ conversationId,
+ senderId: "user-other",
+ recipientId: currentUserId,
+ body: "First message",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ },
+ } as const;
+
+ // 1. Try to append - should be rejected as duplicate
+ queryClient.setQueryData(detailKey, (old) => {
+ if (!old) return old;
+ const alreadyPresent = old.messages?.some(
+ (m) => m.id === duplicateMessage.message.id,
+ );
+ if (alreadyPresent) return old; // Keep original, don't add duplicate
+ return {
+ ...old,
+ messages: [...(old.messages ?? []), duplicateMessage.message],
+ lastMessage: duplicateMessage.message,
+ updatedAt: duplicateMessage.message.createdAt,
+ };
+ });
+
+ const thread = queryClient.getQueryData([
+ "conversations",
+ conversationId,
+ ]);
+
+ // Messages length should stay the same (no duplicate added)
+ expect(thread?.messages?.length).toBe(1);
+ });
+});
\ No newline at end of file
diff --git a/src/features/messaging/hooks/__tests__/useMarkConversationRead.test.tsx b/src/features/messaging/hooks/__tests__/useMarkConversationRead.test.tsx
new file mode 100644
index 00000000..510a31ac
--- /dev/null
+++ b/src/features/messaging/hooks/__tests__/useMarkConversationRead.test.tsx
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useMarkConversationRead } from "../useMarkConversationRead";
+import { messagingService } from "../../api/messagingService";
+import type { Conversation } from "../../types";
+
+vi.mock("../../api/messagingService", () => ({
+ messagingService: {
+ markRead: vi.fn(),
+ },
+}));
+
+const mockMarkRead = vi.mocked(messagingService.markRead);
+
+const CONVERSATION: Conversation = {
+ id: "conv-1",
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: ["user-current", "user-owner"],
+ unreadCount: 3,
+ updatedAt: new Date().toISOString(),
+};
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ return { queryClient, wrapper };
+}
+
+function seedConversation(queryClient: QueryClient, conversation: Conversation) {
+ queryClient.setQueryData(["conversations", "conv-1"], conversation);
+ queryClient.setQueryData(["conversations"], [conversation]);
+}
+
+describe("useMarkConversationRead", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("optimistically sets unreadCount to 0 before the API resolves", async () => {
+ let resolveRequest: (() => void) | undefined;
+ mockMarkRead.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve;
+ }),
+ );
+
+ const { queryClient, wrapper } = createWrapper();
+ seedConversation(queryClient, CONVERSATION);
+
+ const { result } = renderHook(() => useMarkConversationRead(), { wrapper });
+
+ act(() => {
+ result.current.markRead("conv-1");
+ });
+
+ await waitFor(() => {
+ const thread = queryClient.getQueryData([
+ "conversations",
+ "conv-1",
+ ]);
+ expect(thread?.unreadCount).toBe(0);
+ });
+
+ const list = queryClient.getQueryData(["conversations"]);
+ expect(list?.find((c) => c.id === "conv-1")?.unreadCount).toBe(0);
+
+ expect(mockMarkRead).toHaveBeenCalledWith("conv-1");
+
+ await act(async () => {
+ resolveRequest?.();
+ });
+ });
+
+ it("rolls back the unreadCount when the API fails", async () => {
+ let rejectRequest: ((reason?: unknown) => void) | undefined;
+ mockMarkRead.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectRequest = reject;
+ }),
+ );
+
+ const { queryClient, wrapper } = createWrapper();
+ seedConversation(queryClient, CONVERSATION);
+
+ const { result } = renderHook(() => useMarkConversationRead(), { wrapper });
+
+ act(() => {
+ result.current.markRead("conv-1");
+ });
+
+ await waitFor(() => {
+ const thread = queryClient.getQueryData([
+ "conversations",
+ "conv-1",
+ ]);
+ expect(thread?.unreadCount).toBe(0);
+ });
+
+ await act(async () => {
+ rejectRequest?.(new Error("network error"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ // unreadCount restored to original value after rollback.
+ const thread = queryClient.getQueryData([
+ "conversations",
+ "conv-1",
+ ]);
+ expect(thread?.unreadCount).toBe(3);
+
+ const list = queryClient.getQueryData(["conversations"]);
+ expect(list?.find((c) => c.id === "conv-1")?.unreadCount).toBe(3);
+ });
+});
diff --git a/src/features/messaging/hooks/__tests__/useSendMessage.test.tsx b/src/features/messaging/hooks/__tests__/useSendMessage.test.tsx
new file mode 100644
index 00000000..b72cc96f
--- /dev/null
+++ b/src/features/messaging/hooks/__tests__/useSendMessage.test.tsx
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useSendMessage } from "../useSendMessage";
+import { messagingService } from "../../api/messagingService";
+import type { Conversation, Message } from "../../types";
+
+vi.mock("../../api/messagingService", () => ({
+ messagingService: {
+ sendMessage: vi.fn(),
+ },
+}));
+
+const mockSendMessage = vi.mocked(messagingService.sendMessage);
+
+const CONVERSATION: Conversation = {
+ id: "conv-1",
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: ["user-current", "user-owner"],
+ messages: [],
+ unreadCount: 0,
+ updatedAt: new Date().toISOString(),
+};
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ return { queryClient, wrapper };
+}
+
+function seedConversation(queryClient: QueryClient, conversation: Conversation) {
+ queryClient.setQueryData(["conversations", "conv-1"], conversation);
+ queryClient.setQueryData(["conversations"], [conversation]);
+}
+
+describe("useSendMessage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("optimistically appends a sending message to the thread", async () => {
+ let resolveRequest: (() => void) | undefined;
+ mockSendMessage.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve;
+ }),
+ );
+
+ const { queryClient, wrapper } = createWrapper();
+ seedConversation(queryClient, CONVERSATION);
+
+ const { result } = renderHook(() => useSendMessage(), { wrapper });
+
+ act(() => {
+ result.current.sendMessage({
+ conversationId: "conv-1",
+ senderId: "user-current",
+ body: "Hello?",
+ });
+ });
+
+ // While in flight, the optimistic message is present in the thread.
+ await waitFor(() => {
+ const thread = queryClient.getQueryData([
+ "conversations",
+ "conv-1",
+ ]);
+ const optimistic = thread?.messages?.find((m) => m.status === "sending");
+ expect(optimistic?.body).toBe("Hello?");
+ expect(optimistic?.id).toContain("optimistic-");
+ });
+
+ expect(mockSendMessage).toHaveBeenCalledWith("conv-1", "Hello?");
+
+ await act(async () => {
+ resolveRequest?.({
+ id: "msg-1",
+ conversationId: "conv-1",
+ senderId: "user-current",
+ recipientId: "user-owner",
+ body: "Hello?",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ });
+ });
+ });
+
+ it("rolls back the optimistic message when the API fails", async () => {
+ mockSendMessage.mockRejectedValueOnce(new Error("network error"));
+
+ const { queryClient, wrapper } = createWrapper();
+ seedConversation(queryClient, CONVERSATION);
+
+ const { result } = renderHook(() => useSendMessage(), { wrapper });
+
+ act(() => {
+ result.current.sendMessage({
+ conversationId: "conv-1",
+ senderId: "user-current",
+ body: "Hello?",
+ });
+ });
+
+ // Wait for the optimistic message to appear in the thread.
+ await waitFor(() => {
+ const thread = queryClient.getQueryData([
+ "conversations",
+ "conv-1",
+ ]);
+ expect(thread?.messages?.some((m) => m.status === "sending")).toBe(true);
+ });
+
+ // Now trigger the rejection to happen (if not already triggered by mock)
+ // and wait for rollback to complete.
+ // With retry: 0, the mutation should fail immediately and onRollback should restore the cache.
+
+ // Verify the optimistic message was removed (rolled back).
+ const thread = queryClient.getQueryData(["conversations", "conv-1"]);
+ expect(thread?.messages?.some((m) => m.status === "sending")).toBe(false);
+ });
+
+ it("allows successful send message", async () => {
+ mockSendMessage.mockResolvedValueOnce({
+ id: "msg-1",
+ conversationId: "conv-1",
+ senderId: "user-current",
+ recipientId: "user-owner",
+ body: "Hello?",
+ createdAt: new Date().toISOString(),
+ status: "sent",
+ });
+
+ const { queryClient, wrapper } = createWrapper();
+ seedConversation(queryClient, CONVERSATION);
+
+ const { result } = renderHook(() => useSendMessage(), { wrapper });
+
+ act(() => {
+ result.current.sendMessage({
+ conversationId: "conv-1",
+ senderId: "user-current",
+ body: "Hello?",
+ });
+ });
+
+ await waitFor(() => expect(result.current.isPending).toBe(false));
+ expect(mockSendMessage).toHaveBeenCalledWith("conv-1", "Hello?");
+ expect(result.current.isError).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/src/features/messaging/hooks/__tests__/useUnreadMessageCount.test.tsx b/src/features/messaging/hooks/__tests__/useUnreadMessageCount.test.tsx
new file mode 100644
index 00000000..d6b1ad23
--- /dev/null
+++ b/src/features/messaging/hooks/__tests__/useUnreadMessageCount.test.tsx
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useUnreadMessageCount } from "../useUnreadMessageCount";
+import type { Conversation } from "../../types";
+
+vi.mock("../useConversations", () => ({
+ useConversations: vi.fn(),
+}));
+
+import { useConversations } from "../useConversations";
+
+const mockUseConversations = vi.mocked(useConversations);
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ return { wrapper };
+}
+
+const CONV_1: Conversation = {
+ id: "conv-1",
+ petId: "pet-1",
+ ownerId: "user-owner",
+ participantIds: ["user-current", "user-owner"],
+ unreadCount: 3,
+ updatedAt: new Date().toISOString(),
+};
+
+const CONV_2: Conversation = {
+ id: "conv-2",
+ petId: "pet-2",
+ ownerId: "user-owner-2",
+ participantIds: ["user-current", "user-owner-2"],
+ unreadCount: 5,
+ updatedAt: new Date().toISOString(),
+};
+
+const CONV_READ: Conversation = {
+ id: "conv-3",
+ petId: "pet-3",
+ ownerId: "user-owner-3",
+ participantIds: ["user-current", "user-owner-3"],
+ unreadCount: 0,
+ updatedAt: new Date().toISOString(),
+};
+
+describe("useUnreadMessageCount", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns 0 when there are no conversations", () => {
+ mockUseConversations.mockReturnValue({ data: undefined, isLoading: false });
+ const { result } = renderHook(() => useUnreadMessageCount(), {
+ wrapper: createWrapper().wrapper,
+ });
+ expect(result.current).toBe(0);
+ });
+
+ it("sums unread counts across all conversations", () => {
+ mockUseConversations.mockReturnValue({
+ data: [CONV_1, CONV_2],
+ isLoading: false,
+ });
+ const { result } = renderHook(() => useUnreadMessageCount(), {
+ wrapper: createWrapper().wrapper,
+ });
+ expect(result.current).toBe(8);
+ });
+
+ it("ignores conversations with zero unread", () => {
+ mockUseConversations.mockReturnValue({
+ data: [CONV_1, CONV_READ],
+ isLoading: false,
+ });
+ const { result } = renderHook(() => useUnreadMessageCount(), {
+ wrapper: createWrapper().wrapper,
+ });
+ expect(result.current).toBe(3);
+ });
+
+ it("returns 0 when all conversations are read", () => {
+ mockUseConversations.mockReturnValue({
+ data: [CONV_READ],
+ isLoading: false,
+ });
+ const { result } = renderHook(() => useUnreadMessageCount(), {
+ wrapper: createWrapper().wrapper,
+ });
+ expect(result.current).toBe(0);
+ });
+});
diff --git a/src/features/messaging/hooks/index.ts b/src/features/messaging/hooks/index.ts
new file mode 100644
index 00000000..ee79bedb
--- /dev/null
+++ b/src/features/messaging/hooks/index.ts
@@ -0,0 +1,10 @@
+export * from "./useConversations";
+export * from "./useUnreadMessageCount";
+export * from "./useConversation";
+export * from "./useSendMessage";
+export * from "./useMarkConversationRead";
+export * from "./useStartConversation";
+export * from "./useBlockConversation";
+export * from "./useReportConversation";
+export * from "./useLiveMessages";
+export * from "./useCurrentUser";
diff --git a/src/features/messaging/hooks/useBlockConversation.ts b/src/features/messaging/hooks/useBlockConversation.ts
new file mode 100644
index 00000000..e02ae6f3
--- /dev/null
+++ b/src/features/messaging/hooks/useBlockConversation.ts
@@ -0,0 +1,79 @@
+import { useCallback, useState } from "react";
+import { useApiMutation } from "../../../hooks/useApiMutation";
+import { messagingService } from "../api/messagingService";
+
+/** localStorage key so blocks survive refreshes without a backend round-trip. */
+export const BLOCKED_USERS_STORAGE_KEY = "petad:messaging:blocked_users";
+
+function readBlockedUsers(): string[] {
+ try {
+ if (typeof localStorage === "undefined") return [];
+ const raw = localStorage.getItem(BLOCKED_USERS_STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function persistBlockedUsers(userIds: string[]): void {
+ try {
+ if (typeof localStorage === "undefined") return;
+ localStorage.setItem(BLOCKED_USERS_STORAGE_KEY, JSON.stringify(userIds));
+ } catch {
+ // Storage unavailable (private mode) — keep in-memory state only.
+ }
+}
+
+/**
+ * useBlockConversation
+ *
+ * Blocks a conversation partner:
+ * - persists the block locally (survives refreshes) and via the API,
+ * - exposes `unblock` to restore the thread,
+ * - exposes `isBlocked` lookups used to hide the composer.
+ */
+export function useBlockConversation() {
+ const [blockedUserIds, setBlockedUserIds] = useState(readBlockedUsers);
+
+ const { mutate: mutateBlock, isPending: isBlocking, error } = useApiMutation<
+ void,
+ string
+ >((ownerId) => messagingService.blockUser(ownerId), {
+ invalidates: [["conversations"]],
+ });
+
+ const block = useCallback(
+ (ownerId: string) => {
+ setBlockedUserIds((prev) => {
+ const next = prev.includes(ownerId) ? prev : [...prev, ownerId];
+ persistBlockedUsers(next);
+ return next;
+ });
+ mutateBlock(ownerId);
+ },
+ [mutateBlock],
+ );
+
+ const unblock = useCallback((ownerId: string) => {
+ setBlockedUserIds((prev) => {
+ const next = prev.filter((id) => id !== ownerId);
+ persistBlockedUsers(next);
+ return next;
+ });
+ }, []);
+
+ const isBlocked = useCallback(
+ (ownerId: string) => blockedUserIds.includes(ownerId),
+ [blockedUserIds],
+ );
+
+ return {
+ blockedUserIds,
+ isBlocked,
+ block,
+ unblock,
+ isBlocking,
+ error,
+ };
+}
diff --git a/src/features/messaging/hooks/useConversation.ts b/src/features/messaging/hooks/useConversation.ts
new file mode 100644
index 00000000..2cd0a819
--- /dev/null
+++ b/src/features/messaging/hooks/useConversation.ts
@@ -0,0 +1,23 @@
+import { useQuery, type QueryKey } from "@tanstack/react-query";
+import { useApiQuery } from "../../../hooks/useApiQuery";
+import { messagingService } from "../api/messagingService";
+import type { Conversation } from "../types";
+
+/**
+ * useConversation
+ *
+ * Loads a single conversation thread. The cache key
+ * `["conversations", conversationId]` is a child of the list key
+ * `["conversations"]`, so invalidations of the list prefix also refresh threads.
+ */
+export function useConversation(conversationId?: string) {
+ const queryKey: QueryKey = conversationId
+ ? ["conversations", conversationId]
+ : ["conversations"];
+
+ return useApiQuery(
+ queryKey,
+ () => messagingService.getConversation(conversationId as string),
+ { enabled: Boolean(conversationId) },
+ );
+}
diff --git a/src/features/messaging/hooks/useConversations.ts b/src/features/messaging/hooks/useConversations.ts
new file mode 100644
index 00000000..b1a96fa0
--- /dev/null
+++ b/src/features/messaging/hooks/useConversations.ts
@@ -0,0 +1,23 @@
+import { useApiQuery } from "../../../hooks/useApiQuery";
+import { messagingService } from "../api/messagingService";
+import type { Conversation } from "../types";
+
+/**
+ * Conversation cache keys:
+ * - ["conversations"] → the user's conversation list (unread counts live here)
+ * - ["conversations", conversationId] → a single conversation thread
+ */
+export const CONVERSATIONS_QUERY_KEY = ["conversations"] as const;
+
+/**
+ * useConversations
+ *
+ * Loads the current user's conversations. Every unread badge / thread reads
+ * from this cache, and live events optimistically rewrite entries here so the
+ * derived unread counts update immediately.
+ */
+export function useConversations() {
+ return useApiQuery(CONVERSATIONS_QUERY_KEY, () =>
+ messagingService.listConversations(),
+ );
+}
diff --git a/src/features/messaging/hooks/useCurrentUser.ts b/src/features/messaging/hooks/useCurrentUser.ts
new file mode 100644
index 00000000..d6f49316
--- /dev/null
+++ b/src/features/messaging/hooks/useCurrentUser.ts
@@ -0,0 +1,58 @@
+import { useCallback, useState, useEffect } from "react";
+
+export interface CurrentUser {
+ id: string;
+ email: string;
+ fullName: string;
+ role: string;
+}
+
+/** localStorage key under which SignInForm persists the logged-in user profile. */
+const CURRENT_USER_STORAGE_KEY = "petad_user";
+
+function readCurrentUser(): CurrentUser | null {
+ try {
+ if (typeof localStorage === "undefined") return null;
+ const raw = localStorage.getItem(CURRENT_USER_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (
+ parsed &&
+ typeof parsed === "object" &&
+ typeof parsed.id === "string"
+ ) {
+ return parsed as CurrentUser;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * useCurrentUser
+ *
+ * Reads the logged-in user profile persisted by `SignInForm` (localStorage key
+ * `petad_user`). Returns `null` when no user is logged in. Reactively updates
+ * if the storage value changes (e.g. after login/logout in another tab).
+ */
+export function useCurrentUser(): CurrentUser | null {
+ const [user, setUser] = useState(() => readCurrentUser());
+
+ useEffect(() => {
+ const handleStorage = () => {
+ setUser(readCurrentUser());
+ };
+
+ window.addEventListener("storage", handleStorage);
+ return () => {
+ window.removeEventListener("storage", handleStorage);
+ };
+ }, []);
+
+ const refresh = useCallback(() => {
+ setUser(readCurrentUser());
+ }, []);
+
+ return { user, refresh };
+}
diff --git a/src/features/messaging/hooks/useLiveMessages.ts b/src/features/messaging/hooks/useLiveMessages.ts
new file mode 100644
index 00000000..1cac23b1
--- /dev/null
+++ b/src/features/messaging/hooks/useLiveMessages.ts
@@ -0,0 +1,170 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { CONVERSATIONS_QUERY_KEY } from "./useConversations";
+import type { Conversation, Message } from "../types";
+
+/** WebSocket path for the real-time messaging stream. */
+const MESSAGING_WS_PATH = "/api/ws/messaging";
+
+interface LiveMessageEvent {
+ type: "new_message";
+ conversationId: string;
+ message: Message;
+}
+
+/**
+ * useLiveMessages
+ *
+ * Subscribes to the real-time messaging WebSocket and merges incoming live
+ * messages into the React Query cache so the conversation thread and unread
+ * counts update immediately without a refetch.
+ *
+ * - Appends the live message to the conversation thread
+ * - Rewrites the conversation list entry (lastMessage + updatedAt)
+ * - Increments `unreadCount` on the list entry when the message is not from
+ * the current user
+ * - Reconnects with backoff on abnormal close
+ *
+ * Must be used inside a component tree that has a QueryClient.
+ */
+export function useLiveMessages({
+ conversationId,
+ currentUserId,
+}: {
+ conversationId: string;
+ currentUserId: string;
+}) {
+ const queryClient = useQueryClient();
+ const wsRef = useRef(null);
+ const reconnectTimerRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+
+ const detailKey = useMemo(
+ () => ["conversations", conversationId] as const,
+ [conversationId],
+ );
+
+ const clearReconnectTimer = useCallback(() => {
+ if (reconnectTimerRef.current !== null) {
+ clearTimeout(reconnectTimerRef.current);
+ reconnectTimerRef.current = null;
+ }
+ }, []);
+
+ const closeSocket = useCallback(() => {
+ clearReconnectTimer();
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ }, [clearReconnectTimer]);
+
+ const mergeLiveMessage = useCallback(
+ (event: LiveMessageEvent) => {
+ if (event.conversationId !== conversationId) return;
+
+ const { message } = event;
+
+ // 1. Append to the conversation thread.
+ queryClient.setQueryData(detailKey, (old) => {
+ if (!old) return old;
+ const alreadyPresent = old.messages?.some(
+ (m) => m.id === message.id,
+ );
+ if (alreadyPresent) return old;
+ return {
+ ...old,
+ messages: [...(old.messages ?? []), message],
+ lastMessage: message,
+ updatedAt: message.createdAt,
+ };
+ });
+
+ // 2. Rewrite the conversation list entry.
+ queryClient.setQueryData(
+ CONVERSATIONS_QUERY_KEY,
+ (list) => {
+ if (!list) return list;
+ return list.map((c) => {
+ if (c.id !== conversationId) return c;
+ const isFromCurrentUser = message.senderId === currentUserId;
+ return {
+ ...c,
+ lastMessage: message,
+ updatedAt: message.createdAt,
+ // Increment unread only when the message is from the counterparty.
+ unreadCount: isFromCurrentUser
+ ? c.unreadCount
+ : (c.unreadCount ?? 0) + 1,
+ };
+ });
+ },
+ );
+ },
+ [queryClient, detailKey, conversationId, currentUserId],
+ );
+
+ const connect = useCallback(() => {
+ if (!mountedRef.current) return;
+
+ const token =
+ localStorage.getItem("auth_token") ??
+ sessionStorage.getItem("auth_token");
+ if (!token) return;
+
+ const protocol =
+ window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}${MESSAGING_WS_PATH}?token=${encodeURIComponent(token)}`;
+
+ try {
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ if (!mountedRef.current) return;
+ };
+
+ ws.onmessage = (event) => {
+ if (!mountedRef.current) return;
+ if (typeof event.data !== "string") return;
+ try {
+ const parsed: unknown = JSON.parse(event.data);
+ if (
+ parsed !== null &&
+ typeof parsed === "object" &&
+ (parsed as { type?: unknown }).type === "new_message"
+ ) {
+ mergeLiveMessage(parsed as LiveMessageEvent);
+ }
+ } catch {
+ // Non-JSON frames are ignored.
+ }
+ };
+
+ ws.onclose = () => {
+ if (!mountedRef.current) return;
+ wsRef.current = null;
+ // Attempt reconnection after a short delay.
+ reconnectTimerRef.current = setTimeout(() => {
+ connect();
+ }, 1000);
+ };
+ } catch {
+ reconnectTimerRef.current = setTimeout(() => {
+ connect();
+ }, 1000);
+ }
+ }, [mergeLiveMessage]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ connect();
+ return () => {
+ mountedRef.current = false;
+ clearReconnectTimer();
+ closeSocket();
+ };
+ }, [connect, clearReconnectTimer, closeSocket]);
+
+ return null;
+}
diff --git a/src/features/messaging/hooks/useMarkConversationRead.ts b/src/features/messaging/hooks/useMarkConversationRead.ts
new file mode 100644
index 00000000..1874d3a4
--- /dev/null
+++ b/src/features/messaging/hooks/useMarkConversationRead.ts
@@ -0,0 +1,71 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useApiMutation } from "../../../hooks/useApiMutation";
+import { messagingService } from "../api/messagingService";
+import { CONVERSATIONS_QUERY_KEY } from "./useConversations";
+import type { Conversation } from "../types";
+
+/**
+ * useMarkConversationRead
+ *
+ * Marks a conversation as read with an optimistic cache update:
+ * 1. Flips `unreadCount` to 0 on the conversation thread and the list entry
+ * immediately.
+ * 2. Fires the read PATCH.
+ * 3. Rolls the optimistic writes back (and re-syncs) when the request fails.
+ */
+export function useMarkConversationRead() {
+ const queryClient = useQueryClient();
+
+ const { mutate, isPending, isError, error } = useApiMutation(
+ (conversationId) => messagingService.markRead(conversationId),
+ {
+ invalidates: [["conversations"]],
+ onOptimisticUpdate: (conversationId) => {
+ const detailKey = ["conversations", conversationId] as const;
+ const detailSnapshot = queryClient.getQueryData(detailKey);
+ const listSnapshot = queryClient.getQueryData(
+ CONVERSATIONS_QUERY_KEY,
+ );
+
+ queryClient.setQueryData(detailKey, (old) =>
+ old ? { ...old, unreadCount: 0 } : old,
+ );
+
+ queryClient.setQueryData(
+ CONVERSATIONS_QUERY_KEY,
+ listSnapshot?.map((conversation) =>
+ conversation.id === conversationId
+ ? { ...conversation, unreadCount: 0 }
+ : conversation,
+ ),
+ );
+
+ return {
+ conversationId,
+ detailSnapshot,
+ listSnapshot,
+ };
+ },
+ onRollback: (snapshot) => {
+ const context = (snapshot ?? {}) as {
+ conversationId?: string;
+ detailSnapshot: Conversation | undefined;
+ listSnapshot: Conversation[] | undefined;
+ };
+ if (!context.conversationId) return;
+ queryClient.setQueryData(
+ ["conversations", context.conversationId],
+ context.detailSnapshot,
+ );
+ queryClient.setQueryData(
+ CONVERSATIONS_QUERY_KEY,
+ context.listSnapshot,
+ );
+ },
+ },
+ );
+
+ const markRead = (conversationId: string) => mutate(conversationId);
+
+ return { markRead, isPending, isError, error };
+}
diff --git a/src/features/messaging/hooks/useMarkMessagesRead.ts b/src/features/messaging/hooks/useMarkMessagesRead.ts
new file mode 100644
index 00000000..e0e0d748
--- /dev/null
+++ b/src/features/messaging/hooks/useMarkMessagesRead.ts
@@ -0,0 +1,154 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { messageService } from "../api/messageService";
+import { decrementUnread, markMessagesRead, unreadAmong, type MessagesCache } from "../messageCache";
+import type { Conversation } from "../types/message";
+import { messageKeys } from "./useMessages";
+
+/** How long a message must stay on screen before it counts as read. */
+export const READ_DELAY_MS = 1000;
+const VISIBLE_RATIO = 0.6;
+
+/** True only while the user could actually be looking at the page. */
+export function isTabActive(): boolean {
+ return document.visibilityState === "visible" && document.hasFocus();
+}
+
+/**
+ * Marks incoming messages read once they've been continuously visible for
+ * ~1 s while the tab is visible AND focused.
+ *
+ * `observe(el)` is a ref callback for each unread incoming message element
+ * (it must carry `data-message-id`). A hidden or blurred tab pauses the timers
+ * and they restart when the tab becomes active again, so nothing is marked
+ * read behind the user's back.
+ */
+export function useMarkMessagesRead(conversationId: string) {
+ const queryClient = useQueryClient();
+ const threadKey = messageKeys.thread(conversationId);
+
+ const markMutation = useMutation({
+ mutationFn: (ids) => messageService.markRead(conversationId, ids),
+ onError: () => {
+ void queryClient.invalidateQueries({ queryKey: threadKey });
+ void queryClient.invalidateQueries({ queryKey: messageKeys.conversations });
+ },
+ });
+ const mutateRef = useRef(markMutation.mutate);
+ useEffect(() => {
+ mutateRef.current = markMutation.mutate;
+ });
+
+ const markRead = useCallback(
+ (ids: string[]) => {
+ const cached = queryClient.getQueryData(threadKey);
+ const unread = unreadAmong(cached, ids);
+ if (unread.length === 0) return;
+
+ queryClient.setQueryData(threadKey, (data) =>
+ markMessagesRead(data, unread, new Date().toISOString()),
+ );
+ queryClient.setQueryData(messageKeys.conversations, (list) =>
+ decrementUnread(list, conversationId, unread.length),
+ );
+ mutateRef.current(unread);
+ },
+ [conversationId, queryClient, threadKey],
+ );
+
+ const visible = useRef(new Set());
+ const timers = useRef(new Map>());
+ const requested = useRef(new Set());
+ const observer = useRef(null);
+
+ const stopTimer = useCallback((id: string) => {
+ const timer = timers.current.get(id);
+ if (timer !== undefined) clearTimeout(timer);
+ timers.current.delete(id);
+ }, []);
+
+ const startTimer = useCallback(
+ (id: string) => {
+ if (timers.current.has(id) || requested.current.has(id) || !isTabActive()) return;
+ timers.current.set(
+ id,
+ setTimeout(() => {
+ timers.current.delete(id);
+ // Re-check at fire time: the tab may have been hidden mid-delay.
+ if (!visible.current.has(id) || !isTabActive()) return;
+ requested.current.add(id);
+ markRead([id]);
+ }, READ_DELAY_MS),
+ );
+ },
+ [markRead],
+ );
+
+ // Pause on hide/blur, resume on show/focus.
+ useEffect(() => {
+ const sync = () => {
+ if (isTabActive()) visible.current.forEach(startTimer);
+ else {
+ timers.current.forEach((timer) => clearTimeout(timer));
+ timers.current.clear();
+ }
+ };
+ document.addEventListener("visibilitychange", sync);
+ window.addEventListener("focus", sync);
+ window.addEventListener("blur", sync);
+ const pending = timers.current;
+ return () => {
+ document.removeEventListener("visibilitychange", sync);
+ window.removeEventListener("focus", sync);
+ window.removeEventListener("blur", sync);
+ pending.forEach((timer) => clearTimeout(timer));
+ pending.clear();
+ };
+ }, [startTimer]);
+
+ useEffect(() => {
+ if (typeof IntersectionObserver === "undefined") return;
+ const io = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ const id = (entry.target as HTMLElement).dataset.messageId;
+ if (!id) continue;
+ if (entry.isIntersecting && entry.intersectionRatio >= VISIBLE_RATIO) {
+ visible.current.add(id);
+ startTimer(id);
+ } else {
+ visible.current.delete(id);
+ stopTimer(id);
+ }
+ }
+ },
+ { threshold: [0, VISIBLE_RATIO, 1] },
+ );
+ observer.current = io;
+ const seen = visible.current;
+ return () => {
+ io.disconnect();
+ observer.current = null;
+ seen.clear();
+ };
+ }, [startTimer, stopTimer]);
+
+ const observe = useCallback(
+ (element: HTMLElement | null) => {
+ if (!element) return;
+ const io = observer.current;
+ io?.observe(element);
+ return () => {
+ io?.unobserve(element);
+ const id = element.dataset.messageId;
+ if (id) {
+ visible.current.delete(id);
+ stopTimer(id);
+ }
+ };
+ },
+ [stopTimer],
+ );
+
+ return { observe };
+}
diff --git a/src/features/messaging/hooks/useMessageSocketSync.ts b/src/features/messaging/hooks/useMessageSocketSync.ts
new file mode 100644
index 00000000..0b64b870
--- /dev/null
+++ b/src/features/messaging/hooks/useMessageSocketSync.ts
@@ -0,0 +1,148 @@
+import { useEffect, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ buildNotificationSocketUrl,
+ createNotificationSocket,
+ type NotificationSocketOptions,
+} from "../../../lib/realtime/notificationSocket";
+import { useMessagingUser } from "./useMessagingUser";
+import { isTabActive } from "./useMarkMessagesRead";
+import { messageKeys } from "./useMessages";
+import {
+ applyMessageToConversations,
+ decrementUnread,
+ markMessagesRead,
+ unreadAmong,
+ upsertMessage,
+ type MessagesCache,
+} from "../messageCache";
+import {
+ MESSAGE_CREATED,
+ MESSAGES_READ,
+ type Conversation,
+ type Message,
+ type MessagesReadPayload,
+} from "../types/message";
+
+/** Socket URL for messages, or `null` when signed out. */
+export function buildMessageSocketUrl(): string | null {
+ let token: string | null = null;
+ try {
+ token = localStorage.getItem("auth_token") ?? sessionStorage.getItem("auth_token");
+ } catch {
+ token = null;
+ }
+ if (!token) return null;
+
+ const base =
+ (import.meta.env.VITE_MESSAGES_WEBSOCKET_URL as string | undefined) ||
+ `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/api/ws/messages`;
+ return buildNotificationSocketUrl(base, token);
+}
+
+export interface UseMessageSocketSyncOptions {
+ /** Thread currently on screen (its incoming messages are read, not counted unread). */
+ activeConversationId?: string;
+ /** Override the socket URL; `null` keeps the socket disconnected. Defaults to `buildMessageSocketUrl()`. */
+ url?: string | null;
+ /** Passed to the socket manager (tests inject a mock WebSocket here). */
+ socketOptions?: Omit;
+}
+
+function isMessage(value: unknown): value is Message {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ typeof (value as Message).id === "string" &&
+ typeof (value as Message).conversationId === "string" &&
+ typeof (value as Message).senderId === "string"
+ );
+}
+
+/**
+ * Keeps the message caches live from the socket, without refetching:
+ *
+ * - `MESSAGE_CREATED` is merged into the thread cache (de-duplicated by id /
+ * clientId) and into the conversation list. An incoming message in a thread
+ * the user isn't looking at bumps that conversation's unread count in place.
+ * - `MESSAGES_READ` updates read state.
+ * - A reconnect triggers exactly one backfill (invalidate) for anything missed
+ * while offline.
+ */
+export function useMessageSocketSync({
+ activeConversationId,
+ url,
+ socketOptions,
+}: UseMessageSocketSyncOptions = {}): void {
+ const queryClient = useQueryClient();
+ const user = useMessagingUser();
+
+ const activeRef = useRef(activeConversationId);
+ const userIdRef = useRef(user?.id);
+ const optionsRef = useRef(socketOptions);
+ useEffect(() => {
+ activeRef.current = activeConversationId;
+ userIdRef.current = user?.id;
+ optionsRef.current = socketOptions;
+ });
+
+ const resolvedUrl = url === undefined ? buildMessageSocketUrl() : url;
+
+ useEffect(() => {
+ if (!resolvedUrl) return;
+
+ const socket = createNotificationSocket({ url: resolvedUrl, ...optionsRef.current });
+ const counted = new Set(); // message ids already counted as unread
+ let hasConnected = false;
+
+ const offCreated = socket.on(MESSAGE_CREATED, ({ envelope }) => {
+ const message = envelope?.payload;
+ if (!isMessage(message)) return;
+
+ const threadKey = messageKeys.thread(message.conversationId);
+ queryClient.setQueryData(threadKey, (data) => upsertMessage(data, message));
+
+ const mine = message.senderId === userIdRef.current;
+ const watching = activeRef.current === message.conversationId && isTabActive();
+ const alreadyCounted = counted.has(message.id);
+ const bump = !mine && !watching && !alreadyCounted && !message.readAt;
+ if (bump) counted.add(message.id);
+
+ queryClient.setQueryData(messageKeys.conversations, (list) =>
+ applyMessageToConversations(list, message, bump),
+ );
+ });
+
+ const offRead = socket.on(MESSAGES_READ, ({ envelope }) => {
+ const payload = envelope?.payload as Partial | undefined;
+ if (!payload?.conversationId || !Array.isArray(payload.messageIds)) return;
+
+ const key = messageKeys.thread(payload.conversationId);
+ const cached = queryClient.getQueryData(key);
+ const newlyRead = unreadAmong(cached, payload.messageIds).length;
+ const readAt = payload.readAt ?? new Date().toISOString();
+ queryClient.setQueryData(key, (data) => markMessagesRead(data, payload.messageIds!, readAt));
+ // Reads by another of my sessions lower my own unread badge.
+ queryClient.setQueryData(messageKeys.conversations, (list) =>
+ decrementUnread(list, payload.conversationId!, newlyRead),
+ );
+ });
+
+ const offOpen = socket.on("open", () => {
+ if (hasConnected) {
+ // Reconnected: fetch whatever was missed while offline, once.
+ void queryClient.invalidateQueries({ queryKey: messageKeys.all });
+ void queryClient.invalidateQueries({ queryKey: messageKeys.conversations });
+ }
+ hasConnected = true;
+ });
+
+ socket.connect();
+ return () => {
+ offCreated();
+ offRead();
+ offOpen();
+ socket.destroy();
+ };
+ }, [queryClient, resolvedUrl]);
+}
diff --git a/src/features/messaging/hooks/useMessages.ts b/src/features/messaging/hooks/useMessages.ts
new file mode 100644
index 00000000..c59feffa
--- /dev/null
+++ b/src/features/messaging/hooks/useMessages.ts
@@ -0,0 +1,44 @@
+import { useInfiniteQuery, useQuery, type InfiniteData } from "@tanstack/react-query";
+import { messageService } from "../api/messageService";
+import type { Conversation, MessagesPage } from "../types/message";
+
+export const messageKeys = {
+ all: ["messages"] as const,
+ thread: (conversationId: string) => ["messages", conversationId] as const,
+ conversations: ["conversations"] as const,
+};
+
+/**
+ * Message thread as an infinite query. Page 0 is the newest page; fetching the
+ * next page loads OLDER messages (scroll up). Use `flattenMessages(data)` for
+ * the chronological list.
+ */
+export function useMessages(conversationId: string) {
+ return useInfiniteQuery<
+ MessagesPage,
+ Error,
+ InfiniteData,
+ ReturnType,
+ string | undefined
+ >({
+ queryKey: messageKeys.thread(conversationId),
+ queryFn: ({ pageParam }) => messageService.getMessages(conversationId, pageParam),
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ enabled: Boolean(conversationId),
+ // The socket keeps the thread live and reconnects backfill it, so avoid
+ // refetching every loaded page on focus.
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useConversations() {
+ return useQuery({
+ queryKey: messageKeys.conversations,
+ queryFn: () => messageService.listConversations(),
+ // Unread badges are patched live by the socket.
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ });
+}
diff --git a/src/features/messaging/hooks/useMessagingUser.ts b/src/features/messaging/hooks/useMessagingUser.ts
new file mode 100644
index 00000000..076b2ac5
--- /dev/null
+++ b/src/features/messaging/hooks/useMessagingUser.ts
@@ -0,0 +1,30 @@
+import { useMemo } from "react";
+import { CURRENT_USER_STORAGE_KEY } from "../../../hooks/useCurrentUser";
+import type { CurrentUser } from "../types/message";
+
+/** Reads the signed-in user's identity from the entry written at login. */
+export function readMessagingUser(): CurrentUser | null {
+ try {
+ const raw =
+ localStorage.getItem(CURRENT_USER_STORAGE_KEY) ??
+ sessionStorage.getItem(CURRENT_USER_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ parsed !== null &&
+ typeof parsed === "object" &&
+ typeof (parsed as CurrentUser).id === "string" &&
+ typeof (parsed as CurrentUser).fullName === "string"
+ ) {
+ return parsed as CurrentUser;
+ }
+ } catch {
+ // Storage unavailable: the UI degrades to treating every sender as "other".
+ }
+ return null;
+}
+
+/** The signed-in user (`null` when unknown). */
+export function useMessagingUser(): CurrentUser | null {
+ return useMemo(() => readMessagingUser(), []);
+}
diff --git a/src/features/messaging/hooks/useReportConversation.ts b/src/features/messaging/hooks/useReportConversation.ts
new file mode 100644
index 00000000..30de9e04
--- /dev/null
+++ b/src/features/messaging/hooks/useReportConversation.ts
@@ -0,0 +1,102 @@
+import { useCallback, useState } from "react";
+import { useApiMutation } from "../../../hooks/useApiMutation";
+import { messagingService } from "../api/messagingService";
+import { reportConversationSchema } from "../schemas/messagingSchemas";
+import type { ReportConversationInput } from "../schemas/messagingSchemas";
+
+/** localStorage key so a submitted report is not re-sent on refresh. */
+export const REPORTED_CONVERSATIONS_STORAGE_KEY =
+ "petad:messaging:reported_conversations";
+
+function readReportedConversations(): string[] {
+ try {
+ if (typeof localStorage === "undefined") return [];
+ const raw = localStorage.getItem(REPORTED_CONVERSATIONS_STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function persistReportedConversations(ids: string[]): void {
+ try {
+ if (typeof localStorage === "undefined") return;
+ localStorage.setItem(
+ REPORTED_CONVERSATIONS_STORAGE_KEY,
+ JSON.stringify(ids),
+ );
+ } catch {
+ // Storage unavailable — keep in-memory state only.
+ }
+}
+
+/**
+ * useReportConversation
+ *
+ * Files a report against a conversation partner:
+ * - Validates the payload with Zod before it reaches the API
+ * - Persists the reported conversation id locally (survives refreshes)
+ * - Exposes `hasReported` lookups so the UI can suppress re-reporting
+ * - Returns a confirmation message that avoids the counterparty's private data
+ */
+export function useReportConversation() {
+ const [reportedConversationIds, setReportedConversationIds] =
+ useState(readReportedConversations);
+ const [reportConfirmation, setReportConfirmation] = useState(
+ null,
+ );
+
+ const { mutate, isPending: isReporting, error } = useApiMutation<
+ { reportId?: string },
+ ReportConversationInput
+ >(async (payload) => {
+ // Zod validation is applied at the call site (ReportConversationDialog);
+ // we also re-validate here as a defense-in-depth measure.
+ const parsed = reportConversationSchema.parse(payload);
+ return messagingService.report(parsed);
+ }, {
+ invalidates: [["conversations"]],
+ onSuccess: (data) => {
+ const id = data?.reportId;
+ if (id) {
+ setReportedConversationIds((prev) => {
+ const next = prev.includes(id) ? prev : [...prev, id];
+ persistReportedConversations(next);
+ return next;
+ });
+ }
+ setReportConfirmation(
+ id
+ ? "Your report has been submitted. Our team will review it."
+ : "Your report has been submitted. Our team will review it.",
+ );
+ },
+ });
+
+ const reportConversation = useCallback(
+ (input: ReportConversationInput) => {
+ setReportConfirmation(null);
+ mutate(input);
+ },
+ [mutate],
+ );
+
+ const clearConfirmation = useCallback(() => {
+ setReportConfirmation(null);
+ }, []);
+
+ const hasReported = useCallback(
+ (conversationId: string) => reportedConversationIds.includes(conversationId),
+ [reportedConversationIds],
+ );
+
+ return {
+ reportConversation,
+ isReporting,
+ error,
+ reportConfirmation,
+ clearConfirmation,
+ hasReported,
+ };
+}
diff --git a/src/features/messaging/hooks/useSendMessage.ts b/src/features/messaging/hooks/useSendMessage.ts
new file mode 100644
index 00000000..fc14ac9d
--- /dev/null
+++ b/src/features/messaging/hooks/useSendMessage.ts
@@ -0,0 +1,156 @@
+import { useCallback } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { messageService, type SendMessageInput } from "../api/messageService";
+import { applyMessageToConversations, findMessage, removeMessage, upsertMessage, type MessagesCache } from "../messageCache";
+import type { Conversation, Message, MessageAttachment } from "../types/message";
+import { isImageFile } from "../uploadRules";
+import { useMessagingUser } from "./useMessagingUser";
+import { messageKeys } from "./useMessages";
+
+interface Outbox {
+ conversationId: string;
+ input: SendMessageInput;
+ objectUrls: string[];
+}
+
+/**
+ * Sends that haven't succeeded yet, by clientId. Module-level so Retry still
+ * works after the user switches threads and comes back.
+ */
+const outbox = new Map();
+
+function newClientId(): string {
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : `c-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+function revoke(entry: Outbox | undefined): void {
+ if (!entry || typeof URL.revokeObjectURL !== "function") return;
+ entry.objectUrls.forEach((url) => URL.revokeObjectURL(url));
+}
+
+function buildAttachments(files: File[], objectUrls: string[]): MessageAttachment[] {
+ return files.map((file, index) => {
+ let url: string | undefined;
+ if (isImageFile(file) && typeof URL.createObjectURL === "function") {
+ url = URL.createObjectURL(file);
+ objectUrls.push(url);
+ }
+ return { id: `tmp-att-${index}`, fileName: file.name, mimeType: file.type, size: file.size, url };
+ });
+}
+
+export interface SendArgs {
+ body: string;
+ files?: File[];
+}
+
+/**
+ * Optimistic send.
+ *
+ * - `send()` puts a temp message (`status: "sending"`) in the thread instantly.
+ * - Success replaces it with the server message (`sent`); failure flips it to
+ * `failed`, where the UI offers `retry` / `remove`.
+ * - Every attempt carries the same `clientId`, and the cache collapses copies
+ * by `clientId`, so a retry (or a socket echo racing the HTTP response)
+ * never duplicates the message.
+ */
+export function useSendMessage(conversationId: string) {
+ const queryClient = useQueryClient();
+ const user = useMessagingUser();
+ const threadKey = messageKeys.thread(conversationId);
+
+ const patchThread = useCallback(
+ (update: (data: MessagesCache | undefined) => MessagesCache | undefined) => {
+ queryClient.setQueryData(threadKey, update);
+ },
+ [queryClient, threadKey],
+ );
+
+ const mutation = useMutation({
+ mutationFn: (input) => messageService.sendMessage(conversationId, input),
+
+ onSuccess: (message, input) => {
+ const sent: Message = { ...message, clientId: input.clientId, status: "sent" };
+ const cached = queryClient.getQueryData(threadKey);
+ if (cached) patchThread((data) => upsertMessage(data, sent));
+ else void queryClient.invalidateQueries({ queryKey: threadKey });
+
+ queryClient.setQueryData(messageKeys.conversations, (list) =>
+ applyMessageToConversations(list, sent, false),
+ );
+ revoke(outbox.get(input.clientId));
+ outbox.delete(input.clientId);
+ },
+
+ onError: (_error, input) => {
+ patchThread((data) => {
+ const existing = findMessage(data, input.clientId);
+ return existing ? upsertMessage(data, { ...existing, status: "failed" }) : data;
+ });
+ },
+ });
+
+ const dispatch = useCallback(
+ (input: SendMessageInput) => {
+ patchThread((data) => {
+ const existing = findMessage(data, input.clientId);
+ if (existing) return upsertMessage(data, { ...existing, status: "sending" });
+
+ const entry = outbox.get(input.clientId);
+ const objectUrls = entry?.objectUrls ?? [];
+ const temp: Message = {
+ id: `tmp-${input.clientId}`,
+ clientId: input.clientId,
+ conversationId,
+ senderId: user?.id ?? "me",
+ senderName: user?.fullName ?? "You",
+ body: input.body,
+ attachments: buildAttachments(input.files ?? [], objectUrls),
+ createdAt: new Date().toISOString(),
+ status: "sending",
+ };
+ return upsertMessage(data, temp);
+ });
+ mutation.mutate(input);
+ },
+ [conversationId, mutation, patchThread, user],
+ );
+
+ const send = useCallback(
+ ({ body, files }: SendArgs): string => {
+ const input: SendMessageInput = { body, files, clientId: newClientId() };
+ outbox.set(input.clientId, { conversationId, input, objectUrls: [] });
+ dispatch(input);
+ return input.clientId;
+ },
+ [conversationId, dispatch],
+ );
+
+ /** Re-sends a failed message with its original clientId. No-op for unknown ids. */
+ const retry = useCallback(
+ (clientId: string) => {
+ const entry = outbox.get(clientId);
+ if (entry) dispatch(entry.input);
+ },
+ [dispatch],
+ );
+
+ /** Discards a failed message. */
+ const remove = useCallback(
+ (clientId: string) => {
+ revoke(outbox.get(clientId));
+ outbox.delete(clientId);
+ patchThread((data) => removeMessage(data, clientId));
+ },
+ [patchThread],
+ );
+
+ return { send, retry, remove, isSending: mutation.isPending };
+}
+
+/** Test helper. */
+export function clearOutbox(): void {
+ outbox.clear();
+}
diff --git a/src/features/messaging/hooks/useStartConversation.ts b/src/features/messaging/hooks/useStartConversation.ts
new file mode 100644
index 00000000..2d723c6a
--- /dev/null
+++ b/src/features/messaging/hooks/useStartConversation.ts
@@ -0,0 +1,74 @@
+import { useCallback, useRef } from "react";
+import { useApiMutation } from "../../../hooks/useApiMutation";
+import { useQueryClient } from "@tanstack/react-query";
+import { messagingService } from "../api/messagingService";
+import { CONVERSATIONS_QUERY_KEY } from "./useConversations";
+import type { Conversation, StartConversationVariables } from "../types";
+
+function findExistingConversation(
+ conversations: Conversation[] | undefined,
+ petId: string,
+ ownerId: string,
+): Conversation | undefined {
+ return (
+ conversations?.find(
+ (conversation) =>
+ conversation.petId === petId &&
+ conversation.ownerId === ownerId,
+ ) ?? undefined
+ );
+}
+
+/**
+ * useStartConversation
+ *
+ * Starts (or reuses) a conversation between the current user and the owner of a
+ * pet. Existing conversations for the same pet + owner pair are reused from the
+ * conversation cache; otherwise a new conversation is created. Rapid repeated
+ * clicks are de-duplicated via an in-flight promise map.
+ */
+export function useStartConversation() {
+ const queryClient = useQueryClient();
+ const inflightRef = useRef
Custody Timeline
- {custodyId && (
- ID: {custodyId}
+ {custodyId && custodyDetails && (
+
)}
diff --git a/src/pages/PetlistingdetailsPage.tsx b/src/pages/PetlistingdetailsPage.tsx
index d7ac44e9..a5eef633 100644
--- a/src/pages/PetlistingdetailsPage.tsx
+++ b/src/pages/PetlistingdetailsPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import dogImage from "../assets/dog.png";
import dog1Image from "../assets/dog_1.png";
import goldenRetriever from "../assets/golden_retriever.png";
+import { MessageOwnerButton } from "../features/messaging/components/MessageOwnerButton";
interface PetListing {
@@ -41,6 +42,7 @@ const mockPet: PetListing = {
location: "Lagos, Nigeria",
images: [dogImage, dog1Image, goldenRetriever, dogImage],
owner: {
+ id: "user-owner-1",
name: "Angela Christoper",
avatar: "https://i.pravatar.cc/150?img=47",
email: "*****",
@@ -420,6 +422,7 @@ export default function PetListingDetailsPage() {
{intLoading ? : null}
{isInterested ? "Interested ✓" : "Show Interest"}
+