Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 58 additions & 6 deletions app/features/messages/conversation-messages.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(":");
Expand Down Expand Up @@ -95,6 +97,36 @@ export function ConversationMessages({
</>
) : null}
</div>
{onCompose ? (
<footer className="mt-5 flex flex-wrap items-center gap-2">
<Button
aria-label={`Reply to message from ${message.fromAddress}`}
className="h-9 min-w-24 rounded-full px-4"
data-compose-action="reply"
data-compose-message-id={message.id}
onClick={() => onCompose(message, "reply")}
size="sm"
type="button"
variant="outline"
>
<Reply />
Reply
</Button>
<Button
aria-label={`Forward message from ${message.fromAddress}`}
className="h-9 min-w-24 rounded-full px-4"
data-compose-action="forward"
data-compose-message-id={message.id}
onClick={() => onCompose(message, "forward")}
size="sm"
type="button"
variant="outline"
>
<Forward />
Forward
</Button>
</footer>
) : null}
</article>
);
}
Expand All @@ -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 (
<div className="flex items-center gap-3 px-4 py-3 sm:px-6" data-thread-messages-control>
<Separator className="flex-1" />
<Button
aria-label={label}
aria-expanded={expanded}
className="shrink-0 rounded-full"
className="size-11 shrink-0 rounded-full p-0 [&_svg]:size-3.5"
data-thread-disclosure-state={expanded ? "expanded" : "collapsed"}
onClick={onToggle}
size="sm"
size="icon"
title={label}
type="button"
variant="outline"
>
<MessagesSquare data-icon />
{expanded ? `Hide ${count} ${noun}` : `${count} earlier ${noun}`}
<span
aria-hidden="true"
className="grid grid-rows-[0.875rem_0.875rem_0.875rem] place-items-center"
>
{expanded ? (
<ArrowDown data-thread-arrow="top-inward" />
) : (
<ArrowUp data-thread-arrow="top-outward" />
)}
<span className="min-w-4 text-center font-mono text-[10px] font-semibold leading-none tabular-nums">
{count}
</span>
{expanded ? (
<ArrowUp data-thread-arrow="bottom-inward" />
) : (
<ArrowDown data-thread-arrow="bottom-outward" />
)}
</span>
</Button>
<Separator className="flex-1" />
</div>
Expand Down
31 changes: 19 additions & 12 deletions app/features/messages/message-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type MessageDetailProps = {
onSent: () => void;
};

type ThreadComposeMode = Extract<ComposeMode, "reply" | "forward">;

type ThreadComposeState = {
message: MessageDetailType;
mode: ThreadComposeMode;
};

export function MessageDetail({
error = null,
isLoading = false,
Expand All @@ -34,10 +41,7 @@ export function MessageDetail({
onDraftsChange,
onSent
}: MessageDetailProps): React.ReactElement {
const [composeMode, setComposeMode] = React.useState<Extract<
ComposeMode,
"reply" | "forward"
> | null>(null);
const [composeState, setComposeState] = React.useState<ThreadComposeState | null>(null);

if (isLoading) {
return <MessageReaderStatus label="Loading conversation" />;
Expand All @@ -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
);
Expand Down Expand Up @@ -101,9 +104,12 @@ export function MessageDetail({
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<ConversationMessages messages={messages} />
<ConversationMessages
messages={messages}
onCompose={(message, mode) => setComposeState({ message, mode })}
/>
<div className="px-4 pb-8 pt-2 sm:px-6">
{composeMode ? (
{composeState ? (
<React.Suspense
fallback={
<div className="grid min-h-60 place-items-center text-sm text-muted-foreground">
Expand All @@ -112,15 +118,16 @@ export function MessageDetail({
}
>
<ComposeDialog
key={`${composeState.mode}:${composeState.message.id}`}
mailboxes={mailboxes}
message={composeTarget}
mode={composeMode}
message={composeState.message}
mode={composeState.mode}
open
presentation="thread"
threadContext={<ConversationMessages compact messages={messages} />}
{...(onDraftsChange ? { onDraftsChange } : {})}
onOpenChange={(nextOpen) => {
if (!nextOpen) setComposeMode(null);
if (!nextOpen) setComposeState(null);
}}
onSent={onSent}
/>
Expand All @@ -132,7 +139,7 @@ export function MessageDetail({
size="lg"
type="button"
variant="outline"
onClick={() => setComposeMode("reply")}
onClick={() => setComposeState({ message: replyTarget, mode: "reply" })}
>
<Reply />
Reply
Expand All @@ -142,7 +149,7 @@ export function MessageDetail({
size="lg"
type="button"
variant="outline"
onClick={() => setComposeMode("forward")}
onClick={() => setComposeState({ message: selected, mode: "forward" })}
>
<Forward />
Forward
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqbase",
"version": "0.1.27",
"version": "0.1.28",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
Expand Down
102 changes: 102 additions & 0 deletions test/unit/app/messages/conversation-message-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -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: "<first@example.com>",
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: "<second@example.com>",
inReplyTo: "<first@example.com>",
references: ["<first@example.com>"],
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(
<ConversationMessages messages={[firstMessage, secondMessage]} onCompose={onCompose} />
);

const firstReply = view.container.querySelector<HTMLButtonElement>(
'[data-compose-action="reply"][data-compose-message-id="msg_1"]'
);
const secondForward = view.container.querySelector<HTMLButtonElement>(
'[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(<ConversationMessages messages={messages} />);
const control = view.container.querySelector<HTMLButtonElement>(
"[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();
});
});
13 changes: 9 additions & 4 deletions test/unit/app/messages/conversation-reader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessageDetail
mailboxes={[]}
Expand All @@ -78,9 +78,11 @@ describe("conversation reader", () => {

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(/>Reply</g)).toHaveLength(3);
expect(html.match(/>Forward</g)).toHaveLength(3);
expect(html).toContain('data-compose-message-id="msg_1"');
expect(html).toContain('data-compose-message-id="msg_2"');
expect(html).toContain('aria-label="Back to messages"');
expect(html).not.toContain('aria-label="Reply"');
expect(html).toContain('aria-label="Archive conversation"');
});

Expand Down Expand Up @@ -156,7 +158,10 @@ describe("conversation reader", () => {
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"');
});
});
13 changes: 13 additions & 0 deletions test/unit/app/render-hook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export async function renderHook<Props, Result>(
};
}

export async function renderComponent(content: ReactNode): Promise<{
container: HTMLDivElement;
unmount: () => Promise<void>;
}> {
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<void> {
await act(async () => {
root.render(content);
Expand Down
3 changes: 2 additions & 1 deletion test/unit/worker/features/updates/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down