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");