diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx
index 530a75c..152b17b 100644
--- a/src/app/smoke-tests/SmokeHarness.tsx
+++ b/src/app/smoke-tests/SmokeHarness.tsx
@@ -5,6 +5,7 @@ import { useCallback, useState } from "react";
import AttachmentList from "@/components/AttachmentList";
import Composer from "@/components/Composer";
import EmailListPanel from "@/components/EmailListPanel";
+import MessageActionBar from "@/components/MessageActionBar";
import { useUnreadCount } from "@/components/UnreadCountProvider";
import {
DEFAULT_FAVICON_HREF,
@@ -22,7 +23,8 @@ export type SmokePanel =
| "target"
| "auto-sync"
| "dark-rendering"
- | "tab-indicator";
+ | "tab-indicator"
+ | "message-actions";
const fixtureEmails: Email[] = [
{
@@ -317,6 +319,30 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) {
{panel === "tab-indicator" && }
+ {panel === "message-actions" && (
+
+
+
+
+ Mobile action alignment
+
+
+ A message with multiple recipients
+
+
+
+
+
+ )}
+
{panel === "target" && (
diff --git a/src/app/smoke-tests/page.tsx b/src/app/smoke-tests/page.tsx
index 1c24284..cf0846f 100644
--- a/src/app/smoke-tests/page.tsx
+++ b/src/app/smoke-tests/page.tsx
@@ -13,6 +13,7 @@ const smokePanels = new Set([
"auto-sync",
"dark-rendering",
"tab-indicator",
+ "message-actions",
]);
export default async function SmokePage({ searchParams }: Props) {
diff --git a/src/components/EmailDetailView.tsx b/src/components/EmailDetailView.tsx
index 882af07..1a78926 100644
--- a/src/components/EmailDetailView.tsx
+++ b/src/components/EmailDetailView.tsx
@@ -1,16 +1,12 @@
-import Link from "next/link";
import { formatAddress, formatAddressList, formatDate, formatFullDate } from "@/lib/format";
import { Email } from "@/lib/types";
import EmailBody from "@/components/EmailBody";
-import PinButton from "@/components/PinButton";
import CalendarEventCard from "@/components/CalendarEventCard";
-import MarkUnreadButton from "@/components/MarkUnreadButton";
import AttachmentList from "@/components/AttachmentList";
import { resolveCalendarEvent } from "@/lib/calendarDetect";
-import NotSpamButton from "@/components/NotSpamButton";
import { getJmapMailboxContext } from "@/lib/jmapServer";
import SenderAvatar from "@/components/SenderAvatar";
-import MailIcon from "@/components/MailIcon";
+import MessageActionBar from "@/components/MessageActionBar";
import Popover from "@/components/Popover";
interface Props {
@@ -132,64 +128,15 @@ export default async function EmailDetailView({ email, downloadUrl, accountId }:
-
-
-
- {hasMultipleRecipients ? "Reply all" : "Reply"}
-
- {hasMultipleRecipients && (
-
- Reply
-
- )}
- {isSpam && inboxMailbox && (
-
- )}
-
-
-
-
- More
-
- >
- }
- triggerClassName="flex min-h-10 items-center gap-1 rounded-md border border-stone-200 px-3 text-xs text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:bg-stone-700 dark:hover:text-stone-100"
- contentClassName="min-w-36 overflow-hidden rounded-lg border border-stone-200 bg-white py-1 text-sm shadow-lg dark:border-stone-700 dark:bg-stone-900"
- >
-
- Forward
-
-
- Print
-
-
-
-
+
{/* Calendar invite card */}
diff --git a/src/components/MessageActionBar.tsx b/src/components/MessageActionBar.tsx
new file mode 100644
index 0000000..35d764d
--- /dev/null
+++ b/src/components/MessageActionBar.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import Link from "next/link";
+import type { ReactNode } from "react";
+import MailIcon from "@/components/MailIcon";
+import MarkUnreadButton from "@/components/MarkUnreadButton";
+import NotSpamButton from "@/components/NotSpamButton";
+import PinButton from "@/components/PinButton";
+import Popover from "@/components/Popover";
+
+interface Props {
+ emailId: string;
+ hasMultipleRecipients: boolean;
+ initiallyPinned: boolean;
+ isSpam: boolean;
+ mailboxIds: Record;
+ inboxMailboxId?: string;
+ className?: string;
+}
+
+function ActionSlot({
+ columns,
+ action,
+ children,
+}: {
+ columns: "col-span-2" | "col-span-3";
+ action: string;
+ children: ReactNode;
+}) {
+ return (
+ *]:min-h-11 [&>*]:w-full [&>*]:justify-center sm:[&>*]:min-h-10 sm:[&>*]:w-auto",
+ ].join(" ")}
+ >
+ {children}
+
+ );
+}
+
+export default function MessageActionBar({
+ emailId,
+ hasMultipleRecipients,
+ initiallyPinned,
+ isSpam,
+ mailboxIds,
+ inboxMailboxId,
+ className = "",
+}: Props) {
+ const showNotSpam = isSpam && !!inboxMailboxId;
+ const secondaryColumns = showNotSpam ? "col-span-3" : "col-span-2";
+
+ return (
+
+
+
+ {hasMultipleRecipients ? "Reply all" : "Reply"}
+
+
+ {hasMultipleRecipients && (
+
+ Reply
+
+ )}
+
+
+ {showNotSpam && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ More
+
+ >
+ }
+ triggerClassName="flex min-h-11 w-full items-center justify-center gap-1 rounded-md border border-stone-200 px-3 text-xs text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:bg-stone-700 dark:hover:text-stone-100 sm:min-h-10 sm:w-auto"
+ contentClassName="min-w-36 overflow-hidden rounded-lg border border-stone-200 bg-white py-1 text-sm shadow-lg dark:border-stone-700 dark:bg-stone-900"
+ >
+
+ Forward
+
+
+ Print
+
+
+
+
+
+ );
+}
diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx
index e1bda34..a99a5f8 100644
--- a/src/components/ThreadView.tsx
+++ b/src/components/ThreadView.tsx
@@ -1,19 +1,14 @@
"use client";
import { useState } from "react";
-import Link from "next/link";
import SenderAvatar from "@/components/SenderAvatar";
import EmailBody from "@/components/EmailBody";
-import PinButton from "@/components/PinButton";
import CalendarEventCard, { CalendarEventData, CalendarResponse } from "@/components/CalendarEventCard";
import AttachmentList from "@/components/AttachmentList";
-import MailIcon from "@/components/MailIcon";
-import MarkUnreadButton from "@/components/MarkUnreadButton";
+import MessageActionBar from "@/components/MessageActionBar";
import { Email } from "@/lib/types";
import { formatAddressList, formatFullDate } from "@/lib/format";
-import NotSpamButton from "@/components/NotSpamButton";
import { visibleAttachments } from "@/lib/attachments";
-import Popover from "@/components/Popover";
// ---------------------------------------------------------------------------
// Body resolution (mirrors email/[id]/page.tsx logic)
@@ -167,61 +162,15 @@ function EmailStackItem({
{/* Action buttons */}
-
-
-
- {hasMultipleRecipients ? "Reply all" : "Reply"}
-
- {hasMultipleRecipients && (
-
- Reply
-
- )}
- {isSpam && inboxMailboxId && (
-
- )}
-
-
-
-
- More
-
- >
- }
- triggerClassName="flex min-h-10 items-center gap-1 rounded-md border border-stone-200 px-3 text-xs text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:bg-stone-700 dark:hover:text-stone-100"
- contentClassName="min-w-36 overflow-hidden rounded-lg border border-stone-200 bg-white py-1 text-sm shadow-lg dark:border-stone-700 dark:bg-stone-900"
- >
-
- Forward
-
-
- Print
-
-
-
-
+
{/* Calendar invite */}
{calendarEvent && (
diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts
index 1dadcea..41f6faf 100644
--- a/tests/smoke/mail.spec.ts
+++ b/tests/smoke/mail.spec.ts
@@ -186,6 +186,52 @@ test("darkens neutral email canvases while preserving image artwork", async ({
);
});
+test("aligns message actions at the iPhone 15 Pro viewport", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 393, height: 852 });
+ await page.goto("/smoke-tests?panel=message-actions");
+
+ const actionBar = page.locator("[data-message-action-bar]");
+ await expect(actionBar).toBeVisible();
+ const actions = await actionBar
+ .locator("[data-message-action]")
+ .evaluateAll((elements) =>
+ Object.fromEntries(
+ elements.map((element) => {
+ const rect = element.getBoundingClientRect();
+ return [
+ element.getAttribute("data-message-action"),
+ {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ right: rect.right,
+ },
+ ];
+ }),
+ ),
+ );
+
+ const primaryReply = actions["reply-primary"];
+ const reply = actions.reply;
+ const pin = actions.pin;
+ const markUnread = actions["mark-unread"];
+ const more = actions.more;
+
+ expect(primaryReply.y).toBeCloseTo(reply.y, 0);
+ expect(pin.y).toBeCloseTo(markUnread.y, 0);
+ expect(markUnread.y).toBeCloseTo(more.y, 0);
+ expect(primaryReply.x).toBeCloseTo(pin.x, 0);
+ expect(reply.right).toBeCloseTo(more.right, 0);
+ expect(
+ [primaryReply, reply, pin, markUnread, more].every(
+ (action) => action.height >= 44,
+ ),
+ ).toBe(true);
+});
+
test("moves through conversations with the keyboard", async ({ page }) => {
await page.goto("/smoke-tests");