diff --git a/CHANGELOG.md b/CHANGELOG.md index 560d709..381fe91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.28 + +- Reply to or forward any expanded message in a conversation while preserving the existing + conversation-level actions after the final message. +- Replace the hidden-message divider label with a counted two-arrow control that points outward to + expand and inward to collapse. + ## 0.1.27 - Show a private Drafts destination in desktop and mobile navigation only when the signed-in user diff --git a/app/features/messages/conversation-messages.tsx b/app/features/messages/conversation-messages.tsx index 503d578..ed771c5 100644 --- a/app/features/messages/conversation-messages.tsx +++ b/app/features/messages/conversation-messages.tsx @@ -1,4 +1,4 @@ -import { Download, MessagesSquare } from "lucide-react"; +import { ArrowDown, ArrowUp, Download, Forward, Reply } from "lucide-react"; import * as React from "react"; import { Badge } from "@/components/ui/badge"; @@ -11,10 +11,12 @@ import type { MessageDetail } from "./types"; export function ConversationMessages({ compact = false, - messages + messages, + onCompose }: { compact?: boolean; messages: MessageDetail[]; + onCompose?: (message: MessageDetail, mode: "reply" | "forward") => void; }): React.ReactElement { const hiddenCount = Math.max(0, messages.length - 2); const threadFingerprint = messages.map((message) => message.id).join(":"); @@ -95,6 +97,36 @@ export function ConversationMessages({ ) : null} + {onCompose ? ( + + ) : null} ); } @@ -110,19 +142,39 @@ function ThreadMessagesDivider({ onToggle: () => void; }): React.ReactElement { const noun = count === 1 ? "message" : "messages"; + const label = expanded ? `Collapse ${count} earlier ${noun}` : `Expand ${count} earlier ${noun}`; return (
diff --git a/app/features/messages/message-detail.tsx b/app/features/messages/message-detail.tsx index 6bbcb24..82c5690 100644 --- a/app/features/messages/message-detail.tsx +++ b/app/features/messages/message-detail.tsx @@ -23,6 +23,13 @@ type MessageDetailProps = { onSent: () => void; }; +type ThreadComposeMode = Extract; + +type ThreadComposeState = { + message: MessageDetailType; + mode: ThreadComposeMode; +}; + export function MessageDetail({ error = null, isLoading = false, @@ -34,10 +41,7 @@ export function MessageDetail({ onDraftsChange, onSent }: MessageDetailProps): React.ReactElement { - const [composeMode, setComposeMode] = React.useState | null>(null); + const [composeState, setComposeState] = React.useState(null); if (isLoading) { return ; @@ -55,7 +59,6 @@ export function MessageDetail({ selected.direction === "inbound" ? selected : ([...messages].reverse().find((message) => message.direction === "inbound") ?? selected); - const composeTarget = composeMode === "reply" ? replyTarget : selected; const isUnread = messages.some( (message) => message.direction === "inbound" && message.readAt === null ); @@ -101,9 +104,12 @@ export function MessageDetail({
- + setComposeState({ message, mode })} + />
- {composeMode ? ( + {composeState ? ( @@ -112,15 +118,16 @@ export function MessageDetail({ } > } {...(onDraftsChange ? { onDraftsChange } : {})} onOpenChange={(nextOpen) => { - if (!nextOpen) setComposeMode(null); + if (!nextOpen) setComposeState(null); }} onSent={onSent} /> @@ -132,7 +139,7 @@ export function MessageDetail({ size="lg" type="button" variant="outline" - onClick={() => setComposeMode("reply")} + onClick={() => setComposeState({ message: replyTarget, mode: "reply" })} > Reply @@ -142,7 +149,7 @@ export function MessageDetail({ size="lg" type="button" variant="outline" - onClick={() => setComposeMode("forward")} + onClick={() => setComposeState({ message: selected, mode: "forward" })} > Forward diff --git a/package.json b/package.json index 131b697..35c6ed1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.27", + "version": "0.1.28", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/unit/app/messages/conversation-message-actions.test.tsx b/test/unit/app/messages/conversation-message-actions.test.tsx new file mode 100644 index 0000000..1b68562 --- /dev/null +++ b/test/unit/app/messages/conversation-message-actions.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { ConversationMessages } from "@/features/messages/conversation-messages"; +import type { MessageDetail } from "@/features/messages/types"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +const firstMessage: MessageDetail = { + id: "msg_1", + threadId: "thr_1", + mailboxId: "mbx_1", + direction: "inbound", + folder: "inbox", + fromAddress: "customer@example.com", + to: ["support@example.com"], + cc: [], + bcc: [], + deliveredToAddress: "support@example.com", + subject: "Account access", + snippet: "I cannot sign in", + textBody: "I cannot sign in.", + htmlAvailable: false, + messageId: "", + inReplyTo: null, + references: [], + attachments: [], + receivedAt: "2026-07-27T14:00:00.000Z", + sentAt: null, + readAt: null, + starredAt: null, + hasAttachments: false, + createdAt: "2026-07-27T14:00:00.000Z" +}; + +const secondMessage: MessageDetail = { + ...firstMessage, + id: "msg_2", + direction: "outbound", + folder: "sent", + fromAddress: "support@example.com", + to: ["customer@example.com"], + textBody: "We can help.", + snippet: "We can help", + messageId: "", + inReplyTo: "", + references: [""], + receivedAt: null, + sentAt: "2026-07-27T14:05:00.000Z", + readAt: "2026-07-27T14:05:00.000Z", + createdAt: "2026-07-27T14:05:00.000Z" +}; + +describe("conversation message actions", () => { + it("targets the exact message selected for Reply or Forward", async () => { + const onCompose = vi.fn(); + const view = await renderComponent( + + ); + + const firstReply = view.container.querySelector( + '[data-compose-action="reply"][data-compose-message-id="msg_1"]' + ); + const secondForward = view.container.querySelector( + '[data-compose-action="forward"][data-compose-message-id="msg_2"]' + ); + await flushHookEffects(() => firstReply?.click()); + await flushHookEffects(() => secondForward?.click()); + + expect(onCompose).toHaveBeenNthCalledWith(1, firstMessage, "reply"); + expect(onCompose).toHaveBeenNthCalledWith(2, secondMessage, "forward"); + + await view.unmount(); + }); + + it("switches the counted thread control between outward and inward arrows", async () => { + const messages = Array.from({ length: 4 }, (_, index) => ({ + ...firstMessage, + id: `msg_${index + 1}`, + textBody: `Message body ${index + 1}` + })); + const view = await renderComponent(); + const control = view.container.querySelector( + "[data-thread-disclosure-state]" + ); + + expect(control?.getAttribute("aria-label")).toBe("Expand 2 earlier messages"); + expect(control?.querySelector('[data-thread-arrow="top-outward"]')).not.toBeNull(); + expect(control?.querySelector('[data-thread-arrow="bottom-outward"]')).not.toBeNull(); + expect(view.container.textContent).not.toContain("Message body 2"); + + await flushHookEffects(() => control?.click()); + + expect(control?.getAttribute("aria-label")).toBe("Collapse 2 earlier messages"); + expect(control?.getAttribute("data-thread-disclosure-state")).toBe("expanded"); + expect(control?.querySelector('[data-thread-arrow="top-inward"]')).not.toBeNull(); + expect(control?.querySelector('[data-thread-arrow="bottom-inward"]')).not.toBeNull(); + expect(view.container.textContent).toContain("Message body 2"); + expect(view.container.textContent).toContain("Message body 3"); + + await view.unmount(); + }); +}); diff --git a/test/unit/app/messages/conversation-reader.test.tsx b/test/unit/app/messages/conversation-reader.test.tsx index 10796dd..d8acf9b 100644 --- a/test/unit/app/messages/conversation-reader.test.tsx +++ b/test/unit/app/messages/conversation-reader.test.tsx @@ -64,7 +64,7 @@ const conversation: ConversationSummary = { }; describe("conversation reader", () => { - it("renders the complete thread and keeps Reply and Forward at the bottom", () => { + it("renders Reply and Forward for every message and keeps the large final actions", () => { const html = renderToStaticMarkup( { expect(html.indexOf("I cannot sign in.")).toBeLessThan(html.indexOf("We can help.")); expect(html.indexOf("We can help.")).toBeLessThan(html.lastIndexOf(">Reply<")); - expect(html).toContain(">Forward<"); + expect(html.match(/>ReplyForward { expect(html).not.toContain("Message body 4"); expect(html).not.toContain("Message body 5"); expect(html).toContain("Message body 6"); - expect(html).toContain("4 earlier messages"); + expect(html).toContain('aria-label="Expand 4 earlier messages"'); expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('data-thread-disclosure-state="collapsed"'); + expect(html).toContain('data-thread-arrow="top-outward"'); + expect(html).toContain('data-thread-arrow="bottom-outward"'); }); }); diff --git a/test/unit/app/render-hook.tsx b/test/unit/app/render-hook.tsx index 04927bc..b925868 100644 --- a/test/unit/app/render-hook.tsx +++ b/test/unit/app/render-hook.tsx @@ -31,6 +31,19 @@ export async function renderHook( }; } +export async function renderComponent(content: ReactNode): Promise<{ + container: HTMLDivElement; + unmount: () => Promise; +}> { + const container = document.createElement("div"); + const root = ReactDOM.createRoot(container); + await render(root, content); + return { + container, + unmount: () => render(root, null) + }; +} + async function render(root: Root, content: ReactNode): Promise { await act(async () => { root.render(content); diff --git a/test/unit/worker/features/updates/service.test.ts b/test/unit/worker/features/updates/service.test.ts index 110b3d0..e6aa781 100644 --- a/test/unit/worker/features/updates/service.test.ts +++ b/test/unit/worker/features/updates/service.test.ts @@ -43,9 +43,10 @@ describe("HQBase updates", () => { expect(compareVersions("0.2.0", "0.1.9")).toBeGreaterThan(0); }); it("rejects a tampered manifest", async () => { + const replacement = envelope.signature.startsWith("A") ? "B" : "A"; await expect( getUpdateStatus({ HQBASE_RELEASE_PUBLIC_KEY: publicKeyBase64 } as WorkerEnv, async () => - Response.json({ ...envelope, signature: `A${envelope.signature.slice(1)}` }) + Response.json({ ...envelope, signature: `${replacement}${envelope.signature.slice(1)}` }) ) ).rejects.toThrow("signature"); });