+ {days.length === 0 && (
+
+ )}
{days.map((day) => {
const dayTitle = day.date.toLocaleDateString("en-US", {
weekday: "long",
diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx
index 5619a26..4737681 100644
--- a/src/components/MobileNav.tsx
+++ b/src/components/MobileNav.tsx
@@ -1,70 +1,104 @@
"use client";
import Link from "next/link";
+import { useEffect, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
+import MailIcon, { type MailIconName } from "@/components/MailIcon";
import { useMailboxCounts } from "@/components/UnreadCountProvider";
import UnreadCountBadge from "@/components/UnreadCountBadge";
-function InboxIcon() {
- return (
-
- );
-}
-
-function DraftsIcon() {
- return (
-
- );
-}
-
-function ComposeIcon() {
- return (
-
- );
+interface Destination {
+ href: string;
+ label: string;
+ icon: MailIconName;
+ badge?: number;
}
-function SentIcon() {
- return (
-
- );
-}
-
-function CalendarIcon() {
- return (
-
- );
+function destinationActive(
+ href: string,
+ pathname: string,
+ from: string | null,
+): boolean {
+ if (href === "/") {
+ return (
+ (pathname === "/" ||
+ pathname.startsWith("/email/") ||
+ pathname.startsWith("/thread/") ||
+ pathname.startsWith("/attachment/")) &&
+ from !== "spam" &&
+ from !== "sent"
+ );
+ }
+ if (href === "/sent") return pathname.startsWith("/sent") || from === "sent";
+ if (href === "/spam") return pathname.startsWith("/spam") || from === "spam";
+ return pathname.startsWith(href);
}
-function SettingsIcon() {
- return (
-
- );
-}
+function DestinationLink({
+ item,
+ active,
+ compact = false,
+ onNavigate,
+}: {
+ item: Destination;
+ active: boolean;
+ compact?: boolean;
+ onNavigate?: () => void;
+}) {
+ if (compact) {
+ return (
+
+
+
{item.label}
+ {!!item.badge &&
}
+
+ );
+ }
-function SpamIcon() {
return (
-
+
+
+
+ {!!item.badge && (
+
+ )}
+
+ {item.label}
+ {active && (
+
+ )}
+
);
}
@@ -73,94 +107,111 @@ export default function MobileNav() {
const searchParams = useSearchParams();
const from = searchParams.get("from");
const counts = useMailboxCounts();
+ const [moreOpen, setMoreOpen] = useState(false);
- const tabs = [
- {
- href: "/",
- label: "Inbox",
- icon:
,
- active:
- (pathname === "/" ||
- pathname.startsWith("/email/") ||
- pathname.startsWith("/thread/") ||
- pathname.startsWith("/attachment/")) &&
- from !== "spam" &&
- from !== "sent",
- badge: counts.inbox,
- },
- {
- href: "/drafts",
- label: "Drafts",
- icon:
,
- active: pathname.startsWith("/drafts"),
- badge: counts.drafts,
- },
- {
- href: "/sent",
- label: "Sent",
- icon:
,
- active: pathname.startsWith("/sent") || from === "sent",
- badge: 0,
- },
- {
- href: "/spam",
- label: "Spam",
- icon:
,
- active: pathname.startsWith("/spam") || from === "spam",
- badge: counts.spam,
- },
- {
- href: "/calendar",
- label: "Calendar",
- icon:
,
- active: pathname.startsWith("/calendar"),
- badge: 0,
- },
- {
- href: "/compose",
- label: "Compose",
- icon:
,
- active: pathname.startsWith("/compose"),
- badge: 0,
- },
- {
- href: "/settings",
- label: "Settings",
- icon:
,
- active: pathname.startsWith("/settings"),
- badge: 0,
- },
+ useEffect(() => {
+ if (!moreOpen) return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") setMoreOpen(false);
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [moreOpen]);
+
+ const primary: Destination[] = [
+ { href: "/", label: "Inbox", icon: "inbox", badge: counts.inbox },
+ { href: "/calendar", label: "Calendar", icon: "calendar" },
+ { href: "/compose", label: "Compose", icon: "compose" },
];
+ const secondary: Destination[] = [
+ { href: "/drafts", label: "Drafts", icon: "drafts", badge: counts.drafts },
+ { href: "/sent", label: "Sent", icon: "sent" },
+ { href: "/spam", label: "Spam", icon: "spam", badge: counts.spam },
+ { href: "/settings", label: "Settings", icon: "settings" },
+ ];
+
+ const secondaryActive = secondary.some((item) =>
+ destinationActive(item.href, pathname, from),
+ );
+
return (
-
+ >
);
}
diff --git a/src/components/NotSpamButton.tsx b/src/components/NotSpamButton.tsx
index 6a67c44..b1e6216 100644
--- a/src/components/NotSpamButton.tsx
+++ b/src/components/NotSpamButton.tsx
@@ -25,7 +25,7 @@ export default function NotSpamButton({ emailId, mailboxIds, inboxMailboxId }: P
{isPending ? "Moving…" : "Not Spam"}
diff --git a/src/components/PinButton.tsx b/src/components/PinButton.tsx
index 7fd305c..54f1edd 100644
--- a/src/components/PinButton.tsx
+++ b/src/components/PinButton.tsx
@@ -42,7 +42,7 @@ export default function PinButton({ emailId, initiallyPinned }: Props) {
onClick={handleClick}
disabled={pending}
className={[
- "inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border transition-colors disabled:opacity-50",
+ "inline-flex min-h-10 items-center gap-1.5 rounded-md border px-3 text-xs transition-colors disabled:opacity-50",
pinned
? "border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 hover:bg-amber-100 dark:hover:bg-amber-950/60"
: "border-stone-200 dark:border-stone-700 text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-800 hover:text-stone-900 dark:hover:text-stone-100",
diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx
index d2bb49b..5e81acc 100644
--- a/src/components/ThreadView.tsx
+++ b/src/components/ThreadView.tsx
@@ -7,6 +7,8 @@ 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 { Email } from "@/lib/types";
import { formatAddressList, formatFullDate } from "@/lib/format";
import NotSpamButton from "@/components/NotSpamButton";
@@ -66,6 +68,8 @@ function EmailStackItem({
const resolved = resolveBody(email);
const isSpam = !!(spamMailboxId && email.mailboxIds[spamMailboxId]);
const downloadableAttachments = visibleAttachments(email.attachments);
+ const hasMultipleRecipients =
+ (email.to?.length ?? 0) + (email.cc?.length ?? 0) > 1;
return (
@@ -161,7 +166,22 @@ function EmailStackItem({
{/* Action buttons */}
-
+
+
+
+ {hasMultipleRecipients ? "Reply all" : "Reply"}
+
+ {hasMultipleRecipients && (
+
+ Reply
+
+ )}
{isSpam && inboxMailboxId && (
)}
-
- Reply
-
-
- Reply All
-
-
- Forward
-
-
- Print
-
+
+
+
+ More
+
+
+
+
+ Forward
+
+
+ Print
+
+
+
{/* Calendar invite */}
diff --git a/src/components/ToastProvider.tsx b/src/components/ToastProvider.tsx
new file mode 100644
index 0000000..7e7bf04
--- /dev/null
+++ b/src/components/ToastProvider.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+interface ToastOptions {
+ message: string;
+ tone?: "default" | "error";
+ actionLabel?: string;
+ onAction?: () => void | Promise
;
+ duration?: number;
+}
+
+interface ToastState extends ToastOptions {
+ id: number;
+}
+
+const ToastContext = createContext<(options: ToastOptions) => void>(() => undefined);
+
+export function useToast() {
+ return useContext(ToastContext);
+}
+
+export default function ToastProvider({ children }: { children: ReactNode }) {
+ const [toast, setToast] = useState(null);
+ const [actionPending, setActionPending] = useState(false);
+ const nextId = useRef(0);
+ const timer = useRef | null>(null);
+
+ const dismiss = useCallback(() => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = null;
+ setToast(null);
+ setActionPending(false);
+ }, []);
+
+ const showToast = useCallback(
+ (options: ToastOptions) => {
+ if (timer.current) clearTimeout(timer.current);
+ const id = ++nextId.current;
+ setActionPending(false);
+ setToast({ ...options, id });
+ timer.current = setTimeout(dismiss, options.duration ?? 5000);
+ },
+ [dismiss],
+ );
+
+ useEffect(() => dismiss, [dismiss]);
+
+ async function runAction() {
+ if (!toast?.onAction || actionPending) return;
+ if (timer.current) clearTimeout(timer.current);
+ setActionPending(true);
+ try {
+ await toast.onAction();
+ dismiss();
+ } catch {
+ setActionPending(false);
+ setToast((current) =>
+ current
+ ? {
+ ...current,
+ message: "That action could not be completed.",
+ tone: "error",
+ actionLabel: undefined,
+ onAction: undefined,
+ }
+ : current,
+ );
+ timer.current = setTimeout(dismiss, 5000);
+ }
+ }
+
+ return (
+
+ {children}
+ {toast && (
+
+
+ {toast.message}
+ {toast.actionLabel && toast.onAction && (
+
+ {actionPending ? "Working…" : toast.actionLabel}
+
+ )}
+
+ ×
+
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/__tests__/compose.test.ts b/src/lib/__tests__/compose.test.ts
index 0a1bb05..be2a4de 100644
--- a/src/lib/__tests__/compose.test.ts
+++ b/src/lib/__tests__/compose.test.ts
@@ -8,6 +8,7 @@ import {
buildForwardQuote,
htmlToPlainText,
normalizeComposeMarkdown,
+ quotedSectionStart,
stripSignatureSeparator,
} from "../compose";
@@ -225,3 +226,30 @@ describe("normalizeComposeMarkdown", () => {
);
});
});
+
+describe("quotedSectionStart", () => {
+ it("finds reply history after the editable body", () => {
+ const markdown =
+ "My reply.\n\n> On Monday, Maya wrote:\n>\n> Original message";
+ assert.equal(quotedSectionStart(markdown), "My reply.\n\n".length);
+ });
+
+ it("finds forwarded history after the editable body", () => {
+ const markdown =
+ "For your review.\n\n---\n\n**---------- Forwarded message ----------**\n\nOriginal";
+ assert.equal(quotedSectionStart(markdown), "For your review.\n\n".length);
+ });
+
+ it("leaves ordinary Markdown fully editable", () => {
+ assert.equal(
+ quotedSectionStart("A paragraph.\n\n> A normal blockquote\n\n---\n\nFooter"),
+ -1
+ );
+ });
+
+ it("uses the earliest history marker", () => {
+ const markdown =
+ "Reply\n\n> On Monday, Maya wrote:\n>\n> Text\n\n---\n\n**---------- Forwarded message ----------**";
+ assert.equal(quotedSectionStart(markdown), "Reply\n\n".length);
+ });
+});
diff --git a/src/lib/compose.ts b/src/lib/compose.ts
index 9d40ac1..9521b99 100644
--- a/src/lib/compose.ts
+++ b/src/lib/compose.ts
@@ -71,6 +71,20 @@ export function normalizeComposeMarkdown(markdown: string): string {
return markdown.replace(/^--[ \t]*$/gm, "--");
}
+/**
+ * Locate reply or forward history so the composer can keep the transport
+ * representation intact while presenting only the editable reply by default.
+ */
+export function quotedSectionStart(markdown: string): number {
+ const replyMatch = /(^|\n)> On [^\n]+ wrote:\n/m.exec(markdown);
+ const forwardMatch =
+ /(^|\n)---\n\n\*\*-+ Forwarded message -+\*\*/m.exec(markdown);
+ const starts = [replyMatch, forwardMatch]
+ .filter((match): match is RegExpExecArray => match !== null)
+ .map((match) => match.index + (match[1] ? 1 : 0));
+ return starts.length > 0 ? Math.min(...starts) : -1;
+}
+
export function buildForwardQuote({
from,
to,