Use the full width for message bodies on mobile (#112)

* Use the full width for message bodies on mobile

The reader indents the subject, sender row and body by the avatar column
(pl-12/pl-16) at every width, which leaves a 64px gutter on a phone. Below
the sm breakpoint the subject, body, attachments and the scheduled-send
banner's wrapper now start at the page edge; wider screens are unchanged.

* improve mobile layout for email content

---------

Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
This commit is contained in:
Nathan DaeilaandHieu Nguyen authored and GitHub committed 2026-10-13 14:09:56 +07:00
1 parent b24535647c
commit f0fddecd2e
4 files changed
+55 -28

No files matched your search

+29 -13
View File
@@ -54,6 +54,7 @@ export default function MessageDetailPage() {
const [previewAttachment, setPreviewAttachment] =
useState<MessageAttachment | null>(null);
const [threadExpanded, setThreadExpanded] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [latestMessagesFirst] = useLatestMessagesFirst();
const { enabled: showFullRecipientAddresses } = useShowFullRecipientAddresses();
const { visible: messageListVisible } = useMessageListVisibility();
@@ -96,6 +97,7 @@ export default function MessageDetailPage() {
useEffect(() => {
setThreadExpanded(false);
setDetailsOpen(false);
}, [messageId]);
if (loading) {
@@ -149,6 +151,11 @@ export default function MessageDetailPage() {
const cloudAttachmentResult = extractCloudAttachments(
bodyDisplay.latestContent,
);
const messageDate = (withTime: boolean) => scheduledAt
? formatScheduledSendTime(scheduledAt, t)
: formatUserDate(message.createdAt, withTime
? { month: "short", day: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }
: { month: "short", day: "2-digit", year: "numeric" });
return (<>
<div className={clsx("flex py-2 h-12 items-center px-2 border-b sticky top-0 bg-white z-40 gap-3", scrolled ? "border-neutral-200" : "border-transparent")}>
@@ -184,7 +191,7 @@ export default function MessageDetailPage() {
<div ref={scrollRef} onScroll={handleScroll} className="h-full overflow-y-auto overscroll-contain scrollbar-gutter-stable flex-1 min-h-0">
{!message.read && <MarkAsRead messageId={message.id} />}
<h1 className={clsx(!isAnyPanelVisible ? "pl-16" : "pl-10", "pr-6 pb-2 pt-2 text-2xl text-neutral-900")} title={message.subject ?? t("list.noSubject")}>
<h1 className={clsx(!isAnyPanelVisible ? "sm:pl-16" : "sm:pl-10", "pl-3 pr-3 pb-2 pt-2 text-2xl text-neutral-900 sm:pr-6")} title={message.subject ?? t("list.noSubject")}>
{message.subject ?? t("list.noSubject")}
</h1>
{/* <div className="px-6">
@@ -210,9 +217,9 @@ export default function MessageDetailPage() {
expandedAll={threadExpanded}
onExpandedAllChange={setThreadExpanded}
/>
<article className={clsx("px-6 py-4", !isAnyPanelVisible && "pl-12")}>
<article className={clsx("px-3 py-4 sm:px-6", !isAnyPanelVisible && "sm:pl-12")}>
<div className="w-full">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1 pb-5 pl-4">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1 pb-5 sm:pl-4">
<div className="flex min-w-0 items-start gap-3">
<ContactAvatar
mailboxId={message.mailboxId}
@@ -237,7 +244,7 @@ export default function MessageDetailPage() {
fromName
)}
</b>{" "}
<span className="text-neutral-500 text-xs flex-1 overflow-hidden text-ellipsis min-w-0">&lt;{fromAddress}&gt;</span>
<span className="hidden sm:block text-neutral-500 text-xs flex-1 overflow-hidden text-ellipsis min-w-0">&lt;{fromAddress}&gt;</span>
</p>
<p className="text-xs text-neutral-500">
{t("source.to").toLowerCase()}{" "}
@@ -247,6 +254,12 @@ export default function MessageDetailPage() {
style={showFullRecipientAddresses ? "full" : "address"}
/>
</p>
<div className="sm:hidden flex items-center gap-2 text-xs text-neutral-500">
<span>{messageDate(false)}</span>
<button type="button" className="text-blue-600 hover:underline" aria-expanded={detailsOpen} onClick={() => setDetailsOpen((open) => !open)}>
{t("message.details")}
</button>
</div>
{ccEntries.length > 0 && (
<p className="text-xs text-neutral-500">
{t("message.cc")} <RecipientList entries={ccEntries} mailboxId={message.mailboxId} style={showFullRecipientAddresses ? "full" : "address"} />
@@ -260,11 +273,7 @@ export default function MessageDetailPage() {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<p className="text-xs">
{scheduledAt
? formatScheduledSendTime(scheduledAt, t)
: formatUserDate(message.createdAt, { month: "short", day: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
<p className="hidden text-xs sm:block">{messageDate(true)}</p>
<ThreadMessageActions
message={currentThreadMessage}
mailboxId={message.mailboxId}
@@ -273,9 +282,16 @@ export default function MessageDetailPage() {
starOnly={!thread.messages.some((threadMessage) => threadMessage.id === message.id) || !thread.messages.some((threadMessage) => threadMessage.id !== message.id)}
/>
</div>
{detailsOpen && (
<dl className="sm:hidden w-full space-y-0.5 break-words pt-2 text-xs text-neutral-500">
<div><dt className="inline font-medium">{t("source.from")}:</dt> <dd className="inline">{fromName} &lt;{fromAddress}&gt;</dd></div>
<div><dt className="inline font-medium">{t("source.to")}:</dt> <dd className="inline">{toEntries.join(", ")}</dd></div>
<div><dt className="inline font-medium">{t("message.date")}:</dt> <dd className="inline">{messageDate(true)}</dd></div>
</dl>
)}
</div>
{scheduledAt && <div className="pl-10"><ScheduledSendBanner messageId={message.id} scheduledAt={scheduledAt} /></div>}
<div className="prose max-w-none pl-16 text-neutral-900">
{scheduledAt && <div className="sm:pl-10"><ScheduledSendBanner messageId={message.id} scheduledAt={scheduledAt} /></div>}
<div className="prose max-w-none text-neutral-900 sm:pl-16">
{htmlBody ? (
<EmailHtmlRenderer key={message.id} className="mx-auto" html={htmlBody} />
) : (
@@ -292,7 +308,7 @@ export default function MessageDetailPage() {
))}
</div>
{cloudAttachmentResult.attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6 pl-16">
<section className="mt-8 border-t border-neutral-100 py-6 sm:pl-16">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
{t("message.cloudFiles", { count: cloudAttachmentResult.attachments.length })}
</h2>
@@ -321,7 +337,7 @@ export default function MessageDetailPage() {
</section>
)}
{attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6 pl-16">
<section className="mt-8 border-t border-neutral-100 py-6 sm:pl-16">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
{t("message.attachments", { count: attachments.length })}
</h2>
@@ -261,16 +261,6 @@ export function MessageActions({
{isMobile && (
<>
<Button
type="button"
variant="ghost"
size="roundedSM"
aria-label={t("message.reply")}
disabled={disabled}
onClick={() => handleReply("reply")}
>
<Reply size={iconSize} />
</Button>
<Button
variant="ghost"
size="roundedSM"
@@ -408,6 +398,10 @@ export function MessageActions({
<Archive className="h-4 w-4" />
{t("common.archive")}
</button>
<button type="button" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 disabled:text-neutral-400" onClick={() => { setMoreOpen(false); void handleReply("reply"); }}>
<Reply className="h-4 w-4" />
{t("message.reply")}
</button>
{canReplyAll && (
<button type="button" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 disabled:text-neutral-400" onClick={() => { setMoreOpen(false); void handleReply("replyAll"); }}>
<ReplyAll className="h-4 w-4" />
@@ -123,6 +123,7 @@ export function ConversationMessageCard({
const { t } = useLanguage();
const [locallyExpanded, setLocallyExpanded] = useState(defaultExpanded);
const [locallyRead, setLocallyRead] = useState(message.read);
const [detailsOpen, setDetailsOpen] = useState(false);
const expanded = locallyExpanded;
const sender = getConversationSender(message, currentAccountName);
const senderEmail = getConversationSenderEmail(message);
@@ -148,9 +149,9 @@ export function ConversationMessageCard({
return (
<article className={cn("bg-white transition-colors px-6", !expanded && "hover:bg-neutral-50", !isAnyPanelVisible && "pl-12")}>
<article className={cn("bg-white transition-colors px-3 sm:px-6", !expanded && "hover:bg-neutral-50", !isAnyPanelVisible && "sm:pl-12")}>
<div className="w-full">
<div className="flex w-full flex-wrap items-start gap-x-3 py-3 pl-4">
<div className="flex w-full flex-wrap items-start gap-x-3 py-3 sm:pl-4">
<button
type="button"
onClick={() => {
@@ -175,7 +176,7 @@ export function ConversationMessageCard({
<div className="flex flex-col">
<span className={cn("truncate text-sm font-semibold mt-1", locallyRead || outbound ? "text-neutral-900" : "font-semibold text-neutral-900")}>
{sender}
{expanded && <span className="text-xs ml-1 opacity-50 font-normal">&lt;{senderEmail}&gt;</span>}
{expanded && <span className="hidden sm:inline text-xs ml-1 opacity-50 font-normal">&lt;{senderEmail}&gt;</span>}
</span>
{expanded && recipients && <span className="text-xs font-normal text-neutral-500">{t("thread.to", { recipients })}</span>}
</div>
@@ -184,7 +185,7 @@ export function ConversationMessageCard({
)}
</span>
</button>
<span className={clsx(!locallyRead ? "font-semibold" : "", "flex shrink-0 items-center gap-2 text-xs mr-2 mt-2")}>
<span className={clsx(!locallyRead ? "font-semibold" : "", "shrink-0 items-center gap-2 text-xs mr-2 mt-2", "hidden sm:flex")}>
{attachments.length > 0 && <Paperclip className="h-3.5 w-3.5" aria-label={t("thread.attachmentCount", { count: attachments.length })} />}
{formatUserDate(message.createdAt, { month: "short", day: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}
</span>
@@ -195,9 +196,23 @@ export function ConversationMessageCard({
ownAddresses={ownAddresses}
starOnly={!expanded}
/>
<div className={clsx("sm:hidden flex w-full items-center gap-2 pl-12 text-xs", locallyRead ? "text-neutral-500" : "font-semibold text-neutral-700")}>
{attachments.length > 0 && <Paperclip className="h-3.5 w-3.5" aria-label={t("thread.attachmentCount", { count: attachments.length })} />}
<span>{formatUserDate(message.createdAt, { month: "short", day: "2-digit", year: "numeric" })}</span>
<button type="button" className="text-blue-600 hover:underline" aria-expanded={detailsOpen} onClick={() => setDetailsOpen((open) => !open)}>
{t("message.details")}
</button>
</div>
{detailsOpen && (
<dl className="sm:hidden w-full space-y-0.5 break-words pt-2 text-xs text-neutral-500">
<div><dt className="inline font-medium">{t("source.from")}:</dt> <dd className="inline">{sender} &lt;{senderEmail}&gt;</dd></div>
{recipients && <div><dt className="inline font-medium">{t("source.to")}:</dt> <dd className="inline">{recipients}</dd></div>}
<div><dt className="inline font-medium">{t("message.date")}:</dt> <dd className="inline">{formatUserDate(message.createdAt, { month: "short", day: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd></div>
</dl>
)}
</div>
{expanded && body && (
<div className="pb-4 pl-16 pt-2">
<div className="pb-4 pt-2 sm:pl-16">
{body.html ? (
<EmailHtmlRenderer html={body.html} />
) : (
+2
View File
@@ -76,6 +76,8 @@
"message.replyAll": "Reply all",
"message.forward": "Forward",
"message.showOriginal": "Show original",
"message.details": "Details",
"message.date": "Date",
"message.blockContact": "Block contact",
"message.star": "Star",
"message.removeStar": "Remove star",