diff --git a/README.md b/README.md index b61e5246..707efc86 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,20 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Clean, minimalist three-pane layout - Dark and light theme support - Responsive design for mobile and desktop +- Navigation rail (desktop icon sidebar + mobile bottom tab bar) - Keyboard shortcuts for power users - Drag-and-drop email organization - Right-click context menus -- Smooth animations and transitions +- Smooth animations and transitions (respects prefers-reduced-motion) - Infinite scroll pagination +- Welcome banner for first-time users +- Confirmation dialogs with promise-based async flow +- Toast notifications with undo action support +- Inline form validation with shake animation feedback +- Empty state patterns with contextual actions +- Login UX polish (error shake, password visibility toggle, session expired banner) +- Safe area inset support for notched devices +- Screen reader live region announcements ### Real-time Updates - Push notifications via JMAP EventSource diff --git a/ROADMAP.md b/ROADMAP.md index 596d1f8d..7b9fa156 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,6 +69,16 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Virtual scrolling for large email lists - [x] Error boundaries - [x] Settings page with preferences +- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar) +- [x] Welcome banner for first-time users (one-time display, localStorage persistence) +- [x] Confirmation dialog component with promise-based useConfirmDialog hook +- [x] Toast notifications with undo action support and typed durations +- [x] Inline form validation with shake animation (email composer, contact form) +- [x] Login UX polish (error shake, TOTP slide animation, password visibility toggle, session expired banner) +- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions) +- [x] WCAG AA reduced-motion media query (global animation/transition reset) +- [x] Safe area inset utilities for notched devices +- [x] Screen reader live region announcements (sr-only) ### Internationalization - [x] English language support @@ -93,6 +103,10 @@ This document tracks the development status and planned features for JMAP Webmai - [x] XSS attack prevention with comprehensive validation - [x] CSP Report-Only headers with per-request nonce - [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) +- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore) +- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset) +- [x] Safe area insets for notched mobile devices +- [x] Screen reader sr-only live region for dynamic announcements ### Identity Management - [x] Multiple sender identities (name, email, signature) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 3c5dcb9c..4241f318 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -23,6 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { EventModal } from "@/components/calendar/event-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; +import { NavigationRail } from "@/components/layout/navigation-rail"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; export default function CalendarPage() { @@ -308,40 +309,53 @@ export default function CalendarPage() { }; return ( -
- router.push("/")} - onPrev={navigatePrev} - onNext={navigateNext} - onToday={goToToday} - onViewModeChange={setViewMode} - onCreateEvent={() => openCreateModal()} - onImport={() => setShowImportModal(true)} - isMobile={isMobile} - /> +
+ {/* Left Navigation Rail */} + {!isMobile && ( +
+ +
+ )} -
- {!isMobile && ( -
- - -
+
+ openCreateModal()} + onImport={() => setShowImportModal(true)} + isMobile={isMobile} + /> + +
+ {!isMobile && ( +
+ + +
+ )} + + {renderView()} +
+ + {/* Mobile Bottom Navigation */} + {isMobile && ( + )} - - {renderView()}
{showEventModal && ( diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index da6b37d1..356a6775 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -5,6 +5,8 @@ import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { ContactList } from "@/components/contacts/contact-list"; import { ContactDetail } from "@/components/contacts/contact-detail"; import { ContactForm } from "@/components/contacts/contact-form"; @@ -17,6 +19,8 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { toast } from "@/stores/toast-store"; import { cn } from "@/lib/utils"; +import { NavigationRail } from "@/components/layout/navigation-rail"; +import { useIsMobile } from "@/hooks/use-media-query"; import type { ContactCard } from "@/lib/jmap/types"; type View = @@ -68,6 +72,8 @@ export default function ContactsPage() { const [view, setView] = useState("list"); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + const isMobile = useIsMobile(); useEffect(() => { if (!isAuthenticated) { @@ -105,7 +111,14 @@ export default function ContactsPage() { const handleDelete = async () => { if (!selectedContact) return; - if (!window.confirm(t("delete_confirm"))) return; + + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("delete_confirm"), + confirmText: t("form.delete"), + variant: "destructive", + }); + if (!confirmed) return; try { if (supportsSync && client) { @@ -178,7 +191,14 @@ export default function ContactsPage() { const handleDeleteGroup = async () => { if (!selectedGroup) return; - if (!window.confirm(t("groups.delete_confirm"))) return; + + const confirmed = await confirmDialog({ + title: t("groups.delete_confirm_title"), + message: t("groups.delete_confirm"), + confirmText: t("form.delete"), + variant: "destructive", + }); + if (!confirmed) return; try { await deleteGroup(supportsSync && client ? client : null, selectedGroup.id); @@ -228,7 +248,14 @@ export default function ContactsPage() { const handleBulkDelete = async () => { if (selectedContactIds.size === 0) return; - if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return; + + const confirmed = await confirmDialog({ + title: t("bulk.delete_confirm_title"), + message: t("bulk.delete_confirm", { count: selectedContactIds.size }), + confirmText: t("bulk.delete"), + variant: "destructive", + }); + if (!confirmed) return; try { await bulkDeleteContacts( @@ -402,7 +429,15 @@ export default function ContactsPage() { return (
-
+ {!isMobile && ( +
+ +
+ )} + +
+
+
-
- {renderRightPanel()} +
+ {renderRightPanel()} +
+
+ + {isMobile && ( + + )}
+ +
); } diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 6d85e3df..a2168780 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuthStore } from "@/stores/auth-store"; import { useConfig } from "@/hooks/use-config"; -import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react"; export default function LoginPage() { const router = useRouter(); @@ -15,13 +16,15 @@ export default function LoginPage() { const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig(); - // All hooks must be called unconditionally at the top const [formData, setFormData] = useState({ username: "", password: "", }); const [showTotpField, setShowTotpField] = useState(false); const [totpCode, setTotpCode] = useState(""); + const [sessionExpired, setSessionExpired] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [shakeError, setShakeError] = useState(false); const [savedUsernames, setSavedUsernames] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); @@ -30,15 +33,33 @@ export default function LoginPage() { const suggestionsRef = useRef(null); const inputRef = useRef(null); const justSelectedSuggestion = useRef(false); + const totpInputRef = useRef(null); + const prevError = useRef(null); - // Set page title useEffect(() => { if (serverUrl) { document.title = appName; } }, [appName, serverUrl]); - // Load saved usernames from localStorage on mount + useEffect(() => { + try { + if (sessionStorage.getItem('session_expired') === 'true') { + setSessionExpired(true); + sessionStorage.removeItem('session_expired'); + } + } catch { /* sessionStorage unavailable */ } + }, []); + + useEffect(() => { + if (error && error !== prevError.current) { + setShakeError(true); + const timer = setTimeout(() => setShakeError(false), 400); + return () => clearTimeout(timer); + } + prevError.current = error; + }, [error]); + useEffect(() => { if (!serverUrl) return; const saved = localStorage.getItem("webmail_usernames"); @@ -62,10 +83,8 @@ export default function LoginPage() { clearError(); }, [formData, clearError]); - // Filter suggestions based on input useEffect(() => { if (!serverUrl) return; - // Skip showing suggestions if we just selected one if (justSelectedSuggestion.current) { justSelectedSuggestion.current = false; return; @@ -79,14 +98,13 @@ export default function LoginPage() { setShowSuggestions(filtered.length > 0); } else if (formData.username === "" && savedUsernames.length > 0) { setFilteredSuggestions(savedUsernames); - setShowSuggestions(false); // Don't show on empty input + setShowSuggestions(false); } else { setShowSuggestions(false); } setSelectedSuggestionIndex(-1); }, [formData.username, savedUsernames, serverUrl]); - // Close suggestions when clicking outside useEffect(() => { if (!serverUrl) return; const handleClickOutside = (event: MouseEvent) => { @@ -100,7 +118,12 @@ export default function LoginPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, [serverUrl]); - // Show loading state while config is being fetched + useEffect(() => { + if (showTotpField && totpInputRef.current) { + totpInputRef.current.focus(); + } + }, [showTotpField]); + if (configLoading) { return (
@@ -112,7 +135,6 @@ export default function LoginPage() { ); } - // Show error if config fetch failed if (configError) { return (
@@ -129,7 +151,6 @@ export default function LoginPage() { ); } - // Show error if JMAP server URL is not configured if (!serverUrl) { return (
@@ -146,7 +167,6 @@ export default function LoginPage() { ); } - // Save username on successful login const saveUsername = (username: string) => { const saved = localStorage.getItem("webmail_usernames"); let usernames: string[] = []; @@ -159,7 +179,6 @@ export default function LoginPage() { } } - // Add username if not already present, keep max 5 recent usernames if (!usernames.includes(username)) { usernames = [username, ...usernames].slice(0, 5); localStorage.setItem("webmail_usernames", JSON.stringify(usernames)); @@ -167,7 +186,6 @@ export default function LoginPage() { } }; - // Remove a username from saved list const removeUsername = (username: string, e: React.MouseEvent) => { e.stopPropagation(); const updated = savedUsernames.filter(u => u !== username); @@ -195,7 +213,6 @@ export default function LoginPage() { justSelectedSuggestion.current = true; setFormData({ ...formData, username }); setShowSuggestions(false); - // Focus password field document.getElementById("password")?.focus(); }; @@ -248,6 +265,28 @@ export default function LoginPage() {
+ {/* Session Expired Banner */} + {sessionExpired && ( +
+ +

+ {t("session_expired")} +

+ +
+ )} + {/* Error Message */} {error && (
@@ -261,8 +300,11 @@ export default function LoginPage() { )} {/* Login Form */} -
-
+ +
(
selectSuggestion(username)} > {username} @@ -310,45 +353,91 @@ export default function LoginPage() { )}
- setFormData({ ...formData, password: e.target.value })} - className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" - placeholder={t("password_placeholder")} - required - autoComplete="current-password" - /> - - {/* TOTP Toggle */} - - - {/* TOTP Input */} - {showTotpField && ( +
setTotpCode(e.target.value.replace(/\D/g, ''))} - className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest" - placeholder={t("totp_placeholder")} - autoComplete="one-time-code" + id="password" + type={showPassword ? "text" : "password"} + value={formData.password} + onChange={(e) => setFormData({ ...formData, password: e.target.value })} + className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" + placeholder={t("password_placeholder")} + required + autoComplete="current-password" /> - )} -
+ +
+ + {/* 2FA Checkbox */} +
+ + {!showTotpField && ( +

+ {t("totp_hint")} +

+ )} +
+ + {/* TOTP Input with slide animation */} +
+
+ setTotpCode(e.target.value.replace(/\D/g, ''))} + className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest" + placeholder={t("totp_placeholder")} + autoComplete="one-time-code" + tabIndex={showTotpField ? 0 : -1} + aria-hidden={!showTotpField} + /> +
+
+
); -} \ No newline at end of file +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 3b790a3c..75fb33bb 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -30,6 +30,8 @@ import { import { DragDropProvider } from "@/contexts/drag-drop-context"; import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel"; import { isFilterEmpty } from "@/lib/jmap/search-utils"; +import { WelcomeBanner } from "@/components/ui/welcome-banner"; +import { NavigationRail } from "@/components/layout/navigation-rail"; export default function Home() { const router = useRouter(); @@ -37,6 +39,7 @@ export default function Home() { const tCommon = useTranslations('common'); const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); + const [composerDraftText, setComposerDraftText] = useState(""); const [initialCheckDone, setInitialCheckDone] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); // Mobile conversation view state @@ -395,7 +398,8 @@ export default function Home() { } }; - const handleReply = () => { + const handleReply = (draftText?: string) => { + setComposerDraftText(draftText || ""); setComposerMode('reply'); setShowComposer(true); }; @@ -718,6 +722,13 @@ export default function Home() { return (
+ {/* Desktop Navigation Rail */} + {!isMobile && !isTablet && ( +
+ +
+ )} + {/* Mobile/Tablet Sidebar Overlay Backdrop */} {(isMobile || isTablet) && sidebarOpen && (
{/* Main Content Area */} -
+
+
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
+ + setShowShortcutsModal(true)} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} @@ -926,6 +941,12 @@ export default function Home() { )}
+
+ + {/* Mobile Bottom Navigation */} + {isMobile && activeView !== "viewer" && ( + + )}
{/* Email Composer Modal */} @@ -952,10 +973,12 @@ export default function Home() { body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', receivedAt: selectedEmail.receivedAt } : undefined} + initialDraftText={composerDraftText} onSend={handleEmailSend} onClose={() => { setShowComposer(false); setComposerMode('compose'); + setComposerDraftText(""); }} onDiscardDraft={handleDiscardDraft} /> @@ -969,6 +992,9 @@ export default function Home() { isOpen={showShortcutsModal} onClose={() => setShowShortcutsModal(false)} /> + + {/* Screen reader live region for dynamic status announcements */} +
); diff --git a/app/globals.css b/app/globals.css index c8752ea3..0ccda18a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -296,6 +296,7 @@ body { animation: slide-in 0.3s ease-out; } + /* Mobile Responsive Utilities */ /* Safe area insets for notched devices (iPhone X+, etc.) */ @@ -385,3 +386,23 @@ body { .animate-slide-in-from-left { animation: slide-in-from-left 0.3s ease-out; } + +/* Reduced motion: respect user OS preference */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Disable backdrop-filter on mobile for performance */ +@media (max-width: 767px) { + .mobile-backdrop { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } +} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index f02d4f36..65b0f39a 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -2,7 +2,7 @@ import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; @@ -10,7 +10,6 @@ import type { CalendarViewMode } from "@/stores/calendar-store"; interface CalendarToolbarProps { selectedDate: Date; viewMode: CalendarViewMode; - onNavigateBack: () => void; onPrev: () => void; onNext: () => void; onToday: () => void; @@ -19,12 +18,12 @@ interface CalendarToolbarProps { onImport?: () => void; isMobile?: boolean; firstDayOfWeek?: number; + onNavigateBack?: () => void; } export function CalendarToolbar({ selectedDate, viewMode, - onNavigateBack, onPrev, onNext, onToday, @@ -60,11 +59,6 @@ export function CalendarToolbar({ return (
- -
@@ -500,7 +520,7 @@ export function EventModal({
-
+
)} -
+
setGivenName(e.target.value)} @@ -156,7 +177,9 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { />
- + setSurname(e.target.value)} @@ -169,17 +192,27 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
{emails.map((entry, i) => ( -
+
+
{ const next = [...emails]; next[i] = { ...next[i], address: e.target.value }; setEmails(next); + if (emailErrors[i]) { + setEmailErrors(prev => { + const n = { ...prev }; + delete n[i]; + return n; + }); + } }} + onBlur={() => handleEmailBlur(i, entry.address)} placeholder={t("email_placeholder")} - className="flex-1" + className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")} /> { const next = [...phones]; diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 9e97e4b4..4da12842 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X } from "lucide-react"; +import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; @@ -17,6 +17,7 @@ interface ContactListProps { onSearchChange: (query: string) => void; onSelectContact: (id: string) => void; onCreateNew: () => void; + onImport?: () => void; supportsSync: boolean; className?: string; selectedContactIds: Set; @@ -35,6 +36,7 @@ export function ContactList({ onSearchChange, onSelectContact, onCreateNew, + onImport, supportsSync, className, selectedContactIds, @@ -158,11 +160,40 @@ export function ContactList({
{sorted.length === 0 ? ( -
- -

- {searchQuery ? t("empty_search") : t("empty_state")} -

+
+ {searchQuery ? ( + <> + +

{t("empty_search")}

+

{t("empty_search_hint")}

+ + + ) : ( + <> + +

{t("empty_state_title")}

+

{t("empty_state_subtitle")}

+
+ + {onImport && ( + + )} +
+ + )}
) : (
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 8a840412..53e8b0a1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1,12 +1,16 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn, formatFileSize } from "@/lib/utils"; +import { debug } from "@/lib/debug"; +import { toast } from "@/stores/toast-store"; import { useAuthStore } from "@/stores/auth-store"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; @@ -27,10 +31,11 @@ interface EmailComposerProps { draftId?: string; fromEmail?: string; identityId?: string; - }) => void; + }) => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; className?: string; + initialDraftText?: string; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: { from?: { email?: string; name?: string }[]; @@ -47,6 +52,7 @@ export function EmailComposer({ onClose, onDiscardDraft, className, + initialDraftText, mode = 'compose', replyTo }: EmailComposerProps) { @@ -84,18 +90,19 @@ export function EmailComposer({ }; const getInitialBody = () => { - if (!replyTo?.body) return ""; + const prefix = initialDraftText || ""; + if (!replyTo?.body) return prefix; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); if (mode === 'forward') { - return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; + return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; + return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; } - return ""; + return prefix; }; const [to, setTo] = useState(getInitialTo()); @@ -109,12 +116,15 @@ export function EmailComposer({ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); - const [attachments, setAttachments] = useState>([]); + const [attachments, setAttachments] = useState>([]); const fileInputRef = useRef(null); + const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); + const [shakeField, setShakeField] = useState(null); const [selectedIdentityId, setSelectedIdentityId] = useState(null); const [subAddressTag, setSubAddressTag] = useState(''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); + const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -132,6 +142,9 @@ export function EmailComposer({ const toInputRef = useRef(null); const ccInputRef = useRef(null); const bccInputRef = useRef(null); + const toDropdownRef = useRef(null); + const ccDropdownRef = useRef(null); + const bccDropdownRef = useRef(null); const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => { if (autocompleteTimeoutRef.current) { @@ -170,6 +183,18 @@ export function EmailComposer({ ref.current?.focus(); }; + const handleAutoBlur = useCallback((e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => { + const dropdownRef = field === 'to' ? toDropdownRef : field === 'cc' ? ccDropdownRef : bccDropdownRef; + const relatedTarget = e.relatedTarget as Node | null; + if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { + return; + } + if (activeAutoField === field) { + setActiveAutoField(null); + setAutoSelectedIndex(-1); + } + }, [activeAutoField]); + const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => { if (!activeAutoField || autocompleteResults.length === 0) return; @@ -235,52 +260,57 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); - // Handle file selection const handleFileSelect = async (event: React.ChangeEvent) => { if (!client || !event.target.files) return; const files = Array.from(event.target.files); - // Add files to attachments list with uploading state - const newAttachments = files.map(file => ({ file, uploading: true })); + // AbortController tracks cancellation state but uploadBlob doesn't accept a signal, + // so abort only prevents post-upload state updates (cosmetic cancellation) + const newAttachments = files.map(file => { + const controller = new AbortController(); + return { file, uploading: true, abortController: controller }; + }); setAttachments(prev => [...prev, ...newAttachments]); - // Upload each file for (let i = 0; i < files.length; i++) { const file = files[i]; + const controller = newAttachments[i].abortController; try { + if (controller?.signal.aborted) continue; const { blobId } = await client.uploadBlob(file); - // Update attachment with blobId + if (controller?.signal.aborted) continue; setAttachments(prev => prev.map(att => att.file === file - ? { ...att, blobId, uploading: false } + ? { ...att, blobId, uploading: false, abortController: undefined } : att ) ); } catch (error) { - console.error(`Failed to upload ${file.name}:`, error); + if (controller?.signal.aborted) continue; + debug.error(`Failed to upload ${file.name}:`, error); + toast.error(t('upload_failed', { filename: file.name })); - // Mark attachment as failed setAttachments(prev => prev.map(att => att.file === file - ? { ...att, uploading: false, error: true } + ? { ...att, uploading: false, error: true, abortController: undefined } : att ) ); } } - // Clear the input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; - // Remove attachment const removeAttachment = (index: number) => { + const att = attachments[index]; + att?.abortController?.abort(); setAttachments(prev => prev.filter((_, i) => i !== index)); }; @@ -292,7 +322,6 @@ export function EmailComposer({ const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); - // Only save if there's some content if (!toAddresses.length && !subject && !body) { return null; } @@ -392,39 +421,62 @@ export function EmailComposer({ }; }, []); + const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); + const hasContent = body || attachments.some(att => att.blobId && !att.uploading); + const canSend = toAddresses.length > 0 && !!subject && hasContent; + + const getSendTooltip = (): string | undefined => { + if (canSend) return undefined; + if (toAddresses.length === 0) return t('validation.recipient_required'); + if (!subject) return t('validation.subject_required'); + if (!hasContent) return t('validation.body_required'); + return undefined; + }; + const handleSend = async () => { - const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); - // Allow sending if we have recipient, subject, and either body text or attachments - const hasContent = body || attachments.some(att => att.blobId && !att.uploading); + if (!canSend) { + const errors: { to?: boolean; subject?: boolean; body?: boolean } = {}; + if (toAddresses.length === 0) errors.to = true; + if (!subject) errors.subject = true; + if (!hasContent) errors.body = true; + setValidationErrors(errors); - if (toAddresses.length > 0 && subject && hasContent) { - // Wait for any pending auto-save to complete and get the latest draft ID - let finalDraftId = draftId; - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - // saveDraft returns the new draft ID after destroy+create + if (errors.to) { + setShakeField('to'); + setTimeout(() => setShakeField(null), 400); + toInputRef.current?.focus(); + } + return; + } + + let finalDraftId = draftId; + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + try { const savedId = await saveDraft(); if (savedId) { finalDraftId = savedId; } + } catch (err) { + debug.error('Failed to save draft before send:', err); } + } - // Get the selected identity or primary identity - const currentIdentity = selectedIdentityId - ? identities.find(id => id.id === selectedIdentityId) - : primaryIdentity; + const currentIdentity = selectedIdentityId + ? identities.find(id => id.id === selectedIdentityId) + : primaryIdentity; - // Generate sub-addressed email if tag is set - const fromEmail = currentIdentity?.email - ? subAddressTag - ? generateSubAddress(currentIdentity.email, subAddressTag) - : currentIdentity.email - : undefined; + const fromEmail = currentIdentity?.email + ? subAddressTag + ? generateSubAddress(currentIdentity.email, subAddressTag) + : currentIdentity.email + : undefined; - onSend?.({ + try { + await onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, @@ -435,7 +487,6 @@ export function EmailComposer({ identityId: currentIdentity?.id, }); - // Reset form setTo(""); setCc(""); setBcc(""); @@ -443,21 +494,27 @@ export function EmailComposer({ setBody(""); setDraftId(null); setSubAddressTag(""); + setValidationErrors({}); + } catch (err) { + debug.error('Failed to send email:', err); + toast.error(t('send_failed')); } }; - const handleClose = () => { - // If there's a draft with content, ask user if they want to discard + const handleClose = async () => { if (draftId && (to || subject || body)) { - const confirmDiscard = window.confirm(t('discard_draft_confirm')); + const confirmed = await confirm({ + title: t('discard_draft_title'), + message: t('discard_draft_confirm'), + confirmText: t('discard'), + variant: "destructive", + }); - if (confirmDiscard) { - // Clear any pending auto-save + if (confirmed) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - // Delete the draft if callback is provided if (onDiscardDraft) { onDiscardDraft(draftId); } @@ -555,7 +612,7 @@ export function EmailComposer({
-
+
{t('to')}:
{ setTo(e.target.value); + if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); handleAutocomplete(e.target.value, 'to'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'to')} - onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} - className="border-0 focus-visible:ring-0" + onBlur={(e) => handleAutoBlur(e, 'to')} + className={cn( + "border-0 focus-visible:ring-0", + validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400" + )} role="combobox" aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0} aria-autocomplete="list" aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined} aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} + aria-invalid={validationErrors.to || undefined} /> + {validationErrors.to && ( +

{t('validation.recipient_required')}

+ )} {activeAutoField === 'to' && autocompleteResults.length > 0 && ( - insertAutocomplete(email, 'to')} /> + insertAutocomplete(email, 'to')} /> )}
@@ -614,7 +679,7 @@ export function EmailComposer({ handleAutocomplete(e.target.value, 'cc'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'cc')} - onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} + onBlur={(e) => handleAutoBlur(e, 'cc')} className="border-0 focus-visible:ring-0" role="combobox" aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} @@ -623,7 +688,7 @@ export function EmailComposer({ aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} /> {activeAutoField === 'cc' && autocompleteResults.length > 0 && ( - insertAutocomplete(email, 'cc')} /> + insertAutocomplete(email, 'cc')} /> )}
@@ -643,7 +708,7 @@ export function EmailComposer({ handleAutocomplete(e.target.value, 'bcc'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')} - onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} + onBlur={(e) => handleAutoBlur(e, 'bcc')} className="border-0 focus-visible:ring-0" role="combobox" aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} @@ -652,7 +717,7 @@ export function EmailComposer({ aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} /> {activeAutoField === 'bcc' && autocompleteResults.length > 0 && ( - insertAutocomplete(email, 'bcc')} /> + insertAutocomplete(email, 'bcc')} /> )}
@@ -664,22 +729,35 @@ export function EmailComposer({ type="text" placeholder={t('subject_placeholder')} value={subject} - onChange={(e) => setSubject(e.target.value)} - className="flex-1 border-0 focus-visible:ring-0" + onChange={(e) => { + setSubject(e.target.value); + if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); + }} + className={cn( + "flex-1 border-0 focus-visible:ring-0", + validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400" + )} + aria-invalid={validationErrors.subject || undefined} />