Add client-side calendar event notification system that evaluates JMAP CalendarEventAlert triggers and displays toast notifications when alert times are reached. Includes configurable notification sound, acknowledged alert persistence, and proactive event fetching for background alerts. Also mounts ToastContainer globally to fix silent toast failures.
976 lines
32 KiB
TypeScript
976 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useRef, useMemo } from "react";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
import { EmailList } from "@/components/email/email-list";
|
|
import { EmailViewer } from "@/components/email/email-viewer";
|
|
import { EmailComposer } from "@/components/email/email-composer";
|
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
|
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
|
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
|
import { useEmailStore } from "@/stores/email-store";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useUIStore } from "@/stores/ui-store";
|
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
|
import { debug } from "@/lib/debug";
|
|
import { playNotificationSound } from "@/lib/notification-sound";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
ErrorBoundary,
|
|
SidebarErrorFallback,
|
|
EmailListErrorFallback,
|
|
EmailViewerErrorFallback,
|
|
ComposerErrorFallback,
|
|
} from "@/components/error";
|
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
|
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
|
|
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
|
|
|
export default function Home() {
|
|
const router = useRouter();
|
|
const t = useTranslations();
|
|
const tCommon = useTranslations('common');
|
|
const [showComposer, setShowComposer] = useState(false);
|
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
|
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
|
// Mobile conversation view state
|
|
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
|
|
|
// Mobile/tablet responsive hooks
|
|
const { isMobile, isTablet } = useDeviceDetection();
|
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore();
|
|
const {
|
|
emails,
|
|
mailboxes,
|
|
selectedEmail,
|
|
selectedMailbox,
|
|
quota,
|
|
isPushConnected,
|
|
newEmailNotification,
|
|
selectEmail,
|
|
selectMailbox,
|
|
selectAllEmails,
|
|
clearSelection,
|
|
fetchMailboxes,
|
|
fetchEmails,
|
|
fetchQuota,
|
|
sendEmail,
|
|
deleteEmail,
|
|
markAsRead,
|
|
toggleStar,
|
|
moveToMailbox,
|
|
searchEmails,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
isLoading,
|
|
isLoadingEmail,
|
|
setLoadingEmail,
|
|
setPushConnected,
|
|
handleStateChange,
|
|
clearNewEmailNotification,
|
|
markAsSpam,
|
|
undoSpam,
|
|
searchFilters,
|
|
isAdvancedSearchOpen,
|
|
setSearchFilters,
|
|
clearSearchFilters,
|
|
toggleAdvancedSearch,
|
|
advancedSearch,
|
|
} = useEmailStore();
|
|
|
|
// Keyboard shortcuts handlers
|
|
const keyboardHandlers = useMemo(() => ({
|
|
onNextEmail: () => {
|
|
if (emails.length === 0) return;
|
|
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
|
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
|
if (nextIndex >= 0 && nextIndex < emails.length) {
|
|
handleEmailSelect(emails[nextIndex]);
|
|
}
|
|
},
|
|
onPreviousEmail: () => {
|
|
if (emails.length === 0) return;
|
|
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
|
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
|
if (prevIndex >= 0 && prevIndex < emails.length) {
|
|
handleEmailSelect(emails[prevIndex]);
|
|
}
|
|
},
|
|
onOpenEmail: () => {
|
|
// Email is already opened when selected
|
|
},
|
|
onCloseEmail: () => {
|
|
selectEmail(null);
|
|
if (isMobile) {
|
|
setActiveView("list");
|
|
}
|
|
if (isTablet) {
|
|
setTabletListVisible(true);
|
|
}
|
|
},
|
|
onReply: () => {
|
|
if (selectedEmail) handleReply();
|
|
},
|
|
onReplyAll: () => {
|
|
if (selectedEmail) handleReplyAll();
|
|
},
|
|
onForward: () => {
|
|
if (selectedEmail) handleForward();
|
|
},
|
|
onToggleStar: () => {
|
|
if (selectedEmail) handleToggleStar();
|
|
},
|
|
onArchive: () => {
|
|
if (selectedEmail) handleArchive();
|
|
},
|
|
onDelete: () => {
|
|
if (selectedEmail) handleDelete();
|
|
},
|
|
onMarkAsUnread: async () => {
|
|
if (selectedEmail && client) {
|
|
await markAsRead(client, selectedEmail.id, false);
|
|
}
|
|
},
|
|
onMarkAsRead: async () => {
|
|
if (selectedEmail && client) {
|
|
await markAsRead(client, selectedEmail.id, true);
|
|
}
|
|
},
|
|
onToggleSpam: () => {
|
|
if (selectedEmail) {
|
|
// Check if we're in junk folder
|
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
|
const isInJunk = currentMailbox?.role === 'junk';
|
|
if (isInJunk) {
|
|
handleUndoSpam();
|
|
} else {
|
|
handleMarkAsSpam();
|
|
}
|
|
}
|
|
},
|
|
onCompose: () => {
|
|
setComposerMode('compose');
|
|
setShowComposer(true);
|
|
},
|
|
onFocusSearch: () => {
|
|
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
|
if (searchInput) {
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
}
|
|
},
|
|
onShowHelp: () => {
|
|
setShowShortcutsModal(true);
|
|
},
|
|
onRefresh: async () => {
|
|
if (client && selectedMailbox) {
|
|
await fetchEmails(client, selectedMailbox);
|
|
}
|
|
},
|
|
onSelectAll: () => {
|
|
selectAllEmails();
|
|
},
|
|
onDeselectAll: () => {
|
|
clearSelection();
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
|
|
|
|
// Initialize keyboard shortcuts
|
|
useKeyboardShortcuts({
|
|
enabled: isAuthenticated && !showComposer,
|
|
emails,
|
|
selectedEmailId: selectedEmail?.id,
|
|
handlers: keyboardHandlers,
|
|
});
|
|
|
|
// Update page title based on context
|
|
useEffect(() => {
|
|
let title = tCommon('app_title');
|
|
|
|
if (showComposer) {
|
|
// Composing email
|
|
const modeText = {
|
|
compose: t('email_composer.new_message'),
|
|
reply: t('email_composer.reply'),
|
|
replyAll: t('email_composer.reply_all'),
|
|
forward: t('email_composer.forward'),
|
|
}[composerMode] || t('email_composer.new_message');
|
|
title = `${modeText} - ${tCommon('app_title')}`;
|
|
} else if (selectedEmail) {
|
|
// Reading email
|
|
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
|
title = `${subject} - ${tCommon('app_title')}`;
|
|
} else if (selectedMailbox && mailboxes.length > 0) {
|
|
// Mailbox view
|
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
|
if (mailbox) {
|
|
const mailboxName = mailbox.name;
|
|
const unreadCount = mailbox.unreadEmails || 0;
|
|
title = unreadCount > 0
|
|
? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}`
|
|
: `${mailboxName} - ${tCommon('app_title')}`;
|
|
}
|
|
}
|
|
|
|
document.title = title;
|
|
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, tCommon]);
|
|
|
|
// Check auth on mount
|
|
useEffect(() => {
|
|
checkAuth().finally(() => {
|
|
setInitialCheckDone(true);
|
|
});
|
|
}, [checkAuth]);
|
|
|
|
// Redirect to login if not authenticated
|
|
useEffect(() => {
|
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
|
router.push('/login');
|
|
}
|
|
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
|
|
|
// Load mailboxes and emails when authenticated (only if not already loaded)
|
|
useEffect(() => {
|
|
if (isAuthenticated && client && mailboxes.length === 0) {
|
|
const loadData = async () => {
|
|
try {
|
|
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
|
await Promise.all([
|
|
fetchMailboxes(client),
|
|
fetchQuota(client)
|
|
]);
|
|
|
|
// Get the selected mailbox (should be inbox by default)
|
|
const state = useEmailStore.getState();
|
|
const selectedMailboxId = state.selectedMailbox;
|
|
|
|
// Fetch emails for the selected mailbox
|
|
if (selectedMailboxId) {
|
|
await fetchEmails(client, selectedMailboxId);
|
|
} else {
|
|
await fetchEmails(client);
|
|
}
|
|
|
|
// Setup push notifications after successful data load
|
|
try {
|
|
// Register state change callback
|
|
client.onStateChange((change) => handleStateChange(change, client));
|
|
|
|
// Start receiving push notifications
|
|
const pushEnabled = client.setupPushNotifications();
|
|
|
|
if (pushEnabled) {
|
|
setPushConnected(true);
|
|
debug.log('[Push] Push notifications successfully enabled');
|
|
} else {
|
|
debug.log('[Push] Push notifications not available on this server');
|
|
}
|
|
} catch (error) {
|
|
// Push notifications are optional - don't break the app if they fail
|
|
debug.log('[Push] Failed to setup push notifications:', error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading email data:', error);
|
|
}
|
|
};
|
|
loadData();
|
|
}
|
|
|
|
// Cleanup push notifications on unmount
|
|
return () => {
|
|
if (client) {
|
|
client.closePushNotifications();
|
|
}
|
|
};
|
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]);
|
|
|
|
// Handle mark-as-read with delay based on settings
|
|
useEffect(() => {
|
|
// Clear any existing timeout when email changes
|
|
if (markAsReadTimeoutRef.current) {
|
|
debug.log('[Mark as Read] Clearing previous timeout');
|
|
clearTimeout(markAsReadTimeoutRef.current);
|
|
markAsReadTimeoutRef.current = null;
|
|
}
|
|
|
|
// Only set timeout if there's a selected email, it's unread, and we have a client
|
|
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
|
|
return;
|
|
}
|
|
|
|
// Get current setting value
|
|
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
|
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
|
|
|
if (markAsReadDelay === -1) {
|
|
// Never mark as read automatically
|
|
debug.log('[Mark as Read] Never mode - email will stay unread');
|
|
} else if (markAsReadDelay === 0) {
|
|
// Mark as read instantly
|
|
debug.log('[Mark as Read] Instant mode - marking as read now');
|
|
markAsRead(client, selectedEmail.id, true);
|
|
} else {
|
|
// Mark as read after delay
|
|
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
|
markAsReadTimeoutRef.current = setTimeout(() => {
|
|
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
|
markAsRead(client, selectedEmail.id, true);
|
|
markAsReadTimeoutRef.current = null;
|
|
}, markAsReadDelay);
|
|
}
|
|
|
|
// Cleanup on unmount or when dependencies change
|
|
return () => {
|
|
if (markAsReadTimeoutRef.current) {
|
|
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
|
clearTimeout(markAsReadTimeoutRef.current);
|
|
markAsReadTimeoutRef.current = null;
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedEmail?.id]);
|
|
|
|
// Handle new email notifications - play sound
|
|
useEffect(() => {
|
|
if (newEmailNotification) {
|
|
playNotificationSound();
|
|
debug.log('New email received:', newEmailNotification.subject);
|
|
clearNewEmailNotification();
|
|
}
|
|
}, [newEmailNotification, clearNewEmailNotification]);
|
|
|
|
// Lock body scroll when sidebar is open on mobile/tablet
|
|
useEffect(() => {
|
|
if ((isMobile || isTablet) && sidebarOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, [isMobile, isTablet, sidebarOpen]);
|
|
|
|
const handleEmailSend = async (data: {
|
|
to: string[];
|
|
cc: string[];
|
|
bcc: string[];
|
|
subject: string;
|
|
body: string;
|
|
draftId?: string;
|
|
fromEmail?: string;
|
|
identityId?: string;
|
|
}) => {
|
|
if (!client) return;
|
|
|
|
try {
|
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId);
|
|
setShowComposer(false);
|
|
|
|
// Refresh the current mailbox to update the UI
|
|
await fetchEmails(client, selectedMailbox);
|
|
} catch (error) {
|
|
console.error("Failed to send email:", error);
|
|
}
|
|
};
|
|
|
|
const handleDiscardDraft = async (draftId: string) => {
|
|
if (!client) return;
|
|
|
|
try {
|
|
await client.deleteEmail(draftId);
|
|
} catch (error) {
|
|
console.error("Failed to discard draft:", error);
|
|
}
|
|
};
|
|
|
|
const handleReply = () => {
|
|
setComposerMode('reply');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
const handleReplyAll = () => {
|
|
setComposerMode('replyAll');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
const handleForward = () => {
|
|
setComposerMode('forward');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
try {
|
|
await deleteEmail(client, selectedEmail.id);
|
|
selectEmail(null);
|
|
} catch (error) {
|
|
console.error("Failed to delete email:", error);
|
|
}
|
|
};
|
|
|
|
const handleArchive = async () => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
// Find archive mailbox
|
|
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
|
if (archiveMailbox) {
|
|
try {
|
|
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
|
selectEmail(null);
|
|
} catch (error) {
|
|
console.error("Failed to archive email:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleToggleStar = async () => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
try {
|
|
await toggleStar(client, selectedEmail.id);
|
|
} catch (error) {
|
|
console.error("Failed to toggle star:", error);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsSpam = async () => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
const emailId = selectedEmail.id;
|
|
|
|
try {
|
|
await markAsSpam(client, emailId);
|
|
|
|
const toastInstance = (await import('sonner')).toast;
|
|
toastInstance.success(t('email_viewer.spam.toast_success'), {
|
|
action: {
|
|
label: t('email_viewer.spam.toast_undo'),
|
|
onClick: async () => {
|
|
try {
|
|
await undoSpam(client, emailId);
|
|
toastInstance.success(t('notifications.email_moved'));
|
|
} catch (_error) {
|
|
console.error("Failed to undo spam:", _error);
|
|
toastInstance.error(t('email_viewer.spam.error'));
|
|
}
|
|
},
|
|
},
|
|
duration: 5000,
|
|
});
|
|
} catch (_error) {
|
|
console.error("Failed to mark as spam:", _error);
|
|
const toastInstance = (await import('sonner')).toast;
|
|
toastInstance.error(t('email_viewer.spam.error'));
|
|
}
|
|
};
|
|
|
|
const handleUndoSpam = async () => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
try {
|
|
await undoSpam(client, selectedEmail.id);
|
|
|
|
const toastInstance = (await import('sonner')).toast;
|
|
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
|
|
|
// Deselect email after moving it out of junk
|
|
selectEmail(null);
|
|
} catch (_error) {
|
|
console.error("Failed to restore email:", _error);
|
|
const toastInstance = (await import('sonner')).toast;
|
|
toastInstance.error(t('email_viewer.spam.error_not_spam'));
|
|
}
|
|
};
|
|
|
|
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
|
if (!client) return;
|
|
|
|
try {
|
|
// Remove any existing color tags
|
|
const email = emails.find(e => e.id === emailId);
|
|
if (!email) return;
|
|
|
|
const keywords = { ...email.keywords };
|
|
|
|
// Remove old color tags - set to false for JMAP to remove them
|
|
Object.keys(keywords).forEach(key => {
|
|
if (key.startsWith("$color:")) {
|
|
keywords[key] = false;
|
|
}
|
|
});
|
|
|
|
// Add new color tag if specified
|
|
if (color) {
|
|
keywords[`$color:${color}`] = true;
|
|
}
|
|
|
|
// Update email keywords via JMAP
|
|
await client.updateEmailKeywords(emailId, keywords);
|
|
|
|
// Update local state
|
|
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
|
|
|
// Refresh emails list to show color in list
|
|
await fetchEmails(client, selectedMailbox);
|
|
} catch (error) {
|
|
console.error("Failed to set color tag:", error);
|
|
}
|
|
};
|
|
|
|
const handleMailboxSelect = async (mailboxId: string) => {
|
|
selectMailbox(mailboxId);
|
|
selectEmail(null); // Clear selected email when switching mailboxes
|
|
|
|
// On mobile, close sidebar and go to list view
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
setActiveView("list");
|
|
}
|
|
|
|
// On tablet, show the list again
|
|
if (isTablet) {
|
|
setTabletListVisible(true);
|
|
}
|
|
|
|
if (client) {
|
|
// If there's an active search, re-run it in the new mailbox
|
|
if (searchQuery) {
|
|
await searchEmails(client, searchQuery);
|
|
} else {
|
|
await fetchEmails(client, mailboxId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
router.push('/login');
|
|
};
|
|
|
|
const handleSearch = async (query: string) => {
|
|
if (!client) return;
|
|
setSearchQuery(query);
|
|
if (!isFilterEmpty(searchFilters)) {
|
|
await advancedSearch(client);
|
|
} else {
|
|
await searchEmails(client, query);
|
|
}
|
|
};
|
|
|
|
const handleClearSearch = async () => {
|
|
setSearchQuery("");
|
|
clearSearchFilters();
|
|
if (client && selectedMailbox) {
|
|
await fetchEmails(client, selectedMailbox);
|
|
}
|
|
};
|
|
|
|
const handleAdvancedSearch = async () => {
|
|
if (!client) return;
|
|
await advancedSearch(client);
|
|
};
|
|
|
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
|
if (!client) return;
|
|
|
|
try {
|
|
await client.downloadBlob(blobId, name, type);
|
|
} catch (error) {
|
|
console.error("Failed to download attachment:", error);
|
|
}
|
|
};
|
|
|
|
const handleQuickReply = async (body: string) => {
|
|
if (!client || !selectedEmail) return;
|
|
|
|
const sender = selectedEmail.from?.[0];
|
|
if (!sender?.email) {
|
|
throw new Error("No sender email found");
|
|
}
|
|
|
|
// Send reply with just the body text
|
|
await sendEmail(
|
|
client,
|
|
[sender.email],
|
|
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
|
body
|
|
);
|
|
|
|
// Refresh emails to show the sent reply
|
|
await fetchEmails(client, selectedMailbox);
|
|
};
|
|
|
|
// Show loading state while checking auth
|
|
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center bg-background">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
|
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Get current mailbox name for mobile header
|
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
|
|
|
// Handle email selection with mobile view switching
|
|
const handleEmailSelect = async (email: { id: string }) => {
|
|
if (!client || !email) return;
|
|
|
|
// Set loading state immediately (keep current email visible)
|
|
setLoadingEmail(true);
|
|
|
|
// On mobile, switch to viewer
|
|
if (isMobile) {
|
|
setActiveView("viewer");
|
|
}
|
|
|
|
// On tablet, hide the list to maximize viewer space
|
|
if (isTablet) {
|
|
setTabletListVisible(false);
|
|
}
|
|
|
|
// Fetch the full content
|
|
try {
|
|
// Find selected mailbox to determine accountId (for shared folders)
|
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
|
// Only pass accountId for shared mailboxes
|
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
|
|
const fullEmail = await client.getEmail(email.id, accountId);
|
|
if (fullEmail) {
|
|
selectEmail(fullEmail);
|
|
// Mark-as-read logic is now handled by useEffect
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch email content:', error);
|
|
} finally {
|
|
setLoadingEmail(false);
|
|
}
|
|
};
|
|
|
|
// Handle back navigation from viewer on mobile
|
|
const handleMobileBack = () => {
|
|
// If in conversation view, clear it
|
|
if (conversationThread) {
|
|
setConversationThread(null);
|
|
setConversationEmails([]);
|
|
}
|
|
selectEmail(null);
|
|
setActiveView("list");
|
|
};
|
|
|
|
// Handle opening conversation view on mobile
|
|
const handleOpenConversation = async (thread: ThreadGroup) => {
|
|
if (!client) return;
|
|
|
|
setConversationThread(thread);
|
|
setIsLoadingConversation(true);
|
|
setActiveView("viewer");
|
|
|
|
try {
|
|
// Fetch complete thread emails
|
|
const emails = await client.getThreadEmails(thread.threadId);
|
|
setConversationEmails(emails);
|
|
} catch (error) {
|
|
console.error('Failed to fetch thread emails:', error);
|
|
// Fall back to thread.emails
|
|
setConversationEmails(thread.emails);
|
|
} finally {
|
|
setIsLoadingConversation(false);
|
|
}
|
|
};
|
|
|
|
// Handle reply from conversation view
|
|
const handleConversationReply = (email: Email) => {
|
|
selectEmail(email);
|
|
setComposerMode('reply');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
const handleConversationReplyAll = (email: Email) => {
|
|
selectEmail(email);
|
|
setComposerMode('replyAll');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
const handleConversationForward = (email: Email) => {
|
|
selectEmail(email);
|
|
setComposerMode('forward');
|
|
setShowComposer(true);
|
|
};
|
|
|
|
return (
|
|
<DragDropProvider>
|
|
<div className="flex h-screen bg-background overflow-hidden">
|
|
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
|
|
{(isMobile || isTablet) && sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
|
<div
|
|
className={cn(
|
|
"flex-shrink-0 h-full z-50",
|
|
// Mobile/Tablet: fixed overlay
|
|
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
|
|
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
|
!sidebarOpen && "max-lg:-translate-x-full",
|
|
// Desktop: normal flow
|
|
"lg:relative lg:translate-x-0"
|
|
)}
|
|
>
|
|
<ErrorBoundary fallback={SidebarErrorFallback}>
|
|
<Sidebar
|
|
mailboxes={mailboxes}
|
|
selectedMailbox={selectedMailbox}
|
|
onMailboxSelect={handleMailboxSelect}
|
|
onCompose={() => {
|
|
setComposerMode('compose');
|
|
setShowComposer(true);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onLogout={handleLogout}
|
|
onSidebarClose={() => setSidebarOpen(false)}
|
|
onSearch={handleSearch}
|
|
onClearSearch={handleClearSearch}
|
|
activeSearchQuery={searchQuery}
|
|
quota={quota}
|
|
isPushConnected={isPushConnected}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex flex-1 min-w-0 h-full">
|
|
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col h-full bg-background border-r border-border",
|
|
// Mobile: full width, hidden when viewing email
|
|
"max-md:flex-1 max-md:border-r-0",
|
|
isMobile && activeView !== "list" && "max-md:hidden",
|
|
// Tablet/Desktop: fixed width with collapse animation
|
|
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm",
|
|
"transition-all duration-200 ease-out",
|
|
// Tablet: collapse when email selected
|
|
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
)}
|
|
>
|
|
{/* Mobile Header for List View */}
|
|
<MobileHeader
|
|
title={currentMailboxName}
|
|
onCompose={() => {
|
|
setComposerMode('compose');
|
|
setShowComposer(true);
|
|
}}
|
|
/>
|
|
|
|
<AdvancedSearchPanel
|
|
filters={searchFilters}
|
|
isOpen={isAdvancedSearchOpen}
|
|
onFiltersChange={setSearchFilters}
|
|
onClear={() => {
|
|
clearSearchFilters();
|
|
if (client) advancedSearch(client);
|
|
}}
|
|
onSearch={handleAdvancedSearch}
|
|
onClose={toggleAdvancedSearch}
|
|
/>
|
|
|
|
<ErrorBoundary fallback={EmailListErrorFallback}>
|
|
<EmailList
|
|
emails={emails}
|
|
selectedEmailId={selectedEmail?.id}
|
|
isLoading={isLoading}
|
|
onEmailSelect={handleEmailSelect}
|
|
onOpenConversation={handleOpenConversation}
|
|
// Context menu handlers
|
|
onReply={(email) => {
|
|
selectEmail(email);
|
|
handleReply();
|
|
}}
|
|
onReplyAll={(email) => {
|
|
selectEmail(email);
|
|
handleReplyAll();
|
|
}}
|
|
onForward={(email) => {
|
|
selectEmail(email);
|
|
handleForward();
|
|
}}
|
|
onMarkAsRead={async (email, read) => {
|
|
if (client) {
|
|
await markAsRead(client, email.id, read);
|
|
}
|
|
}}
|
|
onToggleStar={async (email) => {
|
|
if (client) {
|
|
await toggleStar(client, email.id);
|
|
}
|
|
}}
|
|
onDelete={async (email) => {
|
|
selectEmail(email);
|
|
await handleDelete();
|
|
}}
|
|
onArchive={async (email) => {
|
|
selectEmail(email);
|
|
await handleArchive();
|
|
}}
|
|
onSetColorTag={(emailId, color) => {
|
|
handleSetColorTag(emailId, color);
|
|
}}
|
|
onMoveToMailbox={async (emailId, mailboxId) => {
|
|
if (client) {
|
|
await moveToMailbox(client, emailId, mailboxId);
|
|
}
|
|
}}
|
|
onMarkAsSpam={async (email) => {
|
|
selectEmail(email);
|
|
await handleMarkAsSpam();
|
|
}}
|
|
onUndoSpam={async (email) => {
|
|
selectEmail(email);
|
|
await handleUndoSpam();
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
|
|
{/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col h-full bg-background",
|
|
// Mobile: full screen overlay when active
|
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
|
// Tablet/Desktop: flex grow
|
|
"md:flex-1 md:relative"
|
|
)}
|
|
>
|
|
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
|
{isMobile && conversationThread ? (
|
|
<ThreadConversationView
|
|
thread={conversationThread}
|
|
emails={conversationEmails}
|
|
isLoading={isLoadingConversation}
|
|
onBack={handleMobileBack}
|
|
onReply={handleConversationReply}
|
|
onReplyAll={handleConversationReplyAll}
|
|
onForward={handleConversationForward}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
onMarkAsRead={async (emailId, read) => {
|
|
if (client) {
|
|
await markAsRead(client, emailId, read);
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Mobile Header for Viewer */}
|
|
{isMobile && activeView === "viewer" && (
|
|
<MobileViewerHeader
|
|
subject={selectedEmail?.subject}
|
|
onBack={handleMobileBack}
|
|
/>
|
|
)}
|
|
|
|
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
|
<EmailViewer
|
|
email={selectedEmail}
|
|
isLoading={isLoadingEmail}
|
|
onReply={handleReply}
|
|
onReplyAll={handleReplyAll}
|
|
onForward={handleForward}
|
|
onDelete={handleDelete}
|
|
onArchive={handleArchive}
|
|
onToggleStar={handleToggleStar}
|
|
onSetColorTag={handleSetColorTag}
|
|
onMarkAsSpam={handleMarkAsSpam}
|
|
onUndoSpam={handleUndoSpam}
|
|
onMarkAsRead={async (emailId, read) => {
|
|
if (client) {
|
|
await markAsRead(client, emailId, read);
|
|
}
|
|
}}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
onQuickReply={handleQuickReply}
|
|
onBack={() => {
|
|
setTabletListVisible(true);
|
|
selectEmail(null);
|
|
}}
|
|
currentUserEmail={client?.["username"]}
|
|
currentUserName={client?.["username"]?.split("@")[0]}
|
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
|
className={isMobile ? "flex-1" : undefined}
|
|
/>
|
|
</ErrorBoundary>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Email Composer Modal */}
|
|
{showComposer && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0">
|
|
<div className={cn(
|
|
"w-full h-full lg:h-[600px] lg:max-w-3xl",
|
|
"max-lg:flex max-lg:flex-col"
|
|
)}>
|
|
<ErrorBoundary
|
|
fallback={ComposerErrorFallback}
|
|
onReset={() => {
|
|
setShowComposer(false);
|
|
setComposerMode('compose');
|
|
}}
|
|
>
|
|
<EmailComposer
|
|
mode={composerMode}
|
|
replyTo={selectedEmail ? {
|
|
from: selectedEmail.from,
|
|
to: selectedEmail.to,
|
|
cc: selectedEmail.cc,
|
|
subject: selectedEmail.subject,
|
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
|
receivedAt: selectedEmail.receivedAt
|
|
} : undefined}
|
|
onSend={handleEmailSend}
|
|
onClose={() => {
|
|
setShowComposer(false);
|
|
setComposerMode('compose');
|
|
}}
|
|
onDiscardDraft={handleDiscardDraft}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Keyboard Shortcuts Modal */}
|
|
<KeyboardShortcutsModal
|
|
isOpen={showShortcutsModal}
|
|
onClose={() => setShowShortcutsModal(false)}
|
|
/>
|
|
</div>
|
|
</DragDropProvider>
|
|
);
|
|
}
|