mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-25 16:01:22 +00:00
feat: Add email threading support with Gmail-style conversation view
- Implement thread grouping by threadId in email list - Add ThreadListItem with collapsed/expanded states for desktop - Create ThreadConversationView for full-screen mobile experience - Add thread utility functions for grouping and sorting - Extend JMAP client with getThread() and getThreadEmails() methods - Add keyboard shortcut 'x' to expand/collapse threads - Add thread expansion state management in email store - Fix React hooks order in login page - Add .env.example for environment configuration - Add husky for git hooks
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# JMAP Webmail Configuration
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# JMAP server URL (required)
|
||||
# This is the URL of your JMAP-compatible mail server
|
||||
NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
+2
-1
@@ -30,8 +30,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx tsc --noEmit && npx eslint . --ext .ts,.tsx
|
||||
@@ -84,7 +84,7 @@
|
||||
- [x] Add attachment upload support
|
||||
- [x] Implement batch operations (mark read/unread, delete multiple)
|
||||
- [x] Implement functional quick reply form
|
||||
- [ ] Add email threading support
|
||||
- [x] Add email threading support (Gmail-style inline expansion)
|
||||
|
||||
### Real-time Updates
|
||||
- [x] Set up EventSource for JMAP push notifications
|
||||
@@ -245,11 +245,39 @@ All settings are now properly wired to their functionality:
|
||||
- **Navigation**: j/k or arrows (next/prev email), Enter/o (open), Esc (close)
|
||||
- **Actions**: r (reply), R/a (reply all), f (forward), s (star), e (archive), #/Del (delete), u (unread)
|
||||
- **Global**: c (compose), / (search), ? (help), Shift+G (refresh), Ctrl+A (select all)
|
||||
- **Threads**: x (expand/collapse thread)
|
||||
- Disabled when typing in inputs or when composer is open
|
||||
|
||||
### Email Threading (2025-12-10)
|
||||
- **Style**: Gmail-style inline expansion (desktop), full-screen conversation view (mobile)
|
||||
- **Files**:
|
||||
- lib/thread-utils.ts - Thread grouping utilities
|
||||
- lib/jmap/types.ts - ThreadGroup interface
|
||||
- lib/jmap/client.ts - getThread(), getThreadEmails() methods
|
||||
- stores/email-store.ts - Thread expansion state (expandedThreadIds, threadEmailsCache)
|
||||
- components/email/thread-list-item.tsx - Collapsed/expanded thread view (desktop), tap-to-open (mobile)
|
||||
- components/email/thread-email-item.tsx - Compact email within thread
|
||||
- components/email/thread-conversation-view.tsx - Full-screen mobile conversation view
|
||||
- components/email/email-list.tsx - Groups emails by threadId
|
||||
- **Desktop Features**:
|
||||
- Threads grouped by threadId (client-side grouping)
|
||||
- Collapsed view shows: participants, email count badge, latest subject/date
|
||||
- Expanded view shows: all emails in thread with indentation
|
||||
- Lazy loading: complete thread fetched via Thread/get on expansion
|
||||
- State preserved per-mailbox, cleared on mailbox switch
|
||||
- Keyboard: x to expand/collapse selected thread
|
||||
- **Mobile Features**:
|
||||
- Tap thread → full-screen conversation view (no inline expansion)
|
||||
- Collapsible email cards, most recent auto-expanded
|
||||
- Full HTML email content with DOMPurify sanitization
|
||||
- External content blocking with user override
|
||||
- Inline attachments with download support
|
||||
- Reply/Reply All/Forward buttons on expanded cards
|
||||
- Back navigation returns to email list
|
||||
|
||||
### Feature Completeness
|
||||
- **Authentication**: ✅ Complete (secure design, no password storage)
|
||||
- **Email Operations**: ✅ Complete except threading
|
||||
- **Email Operations**: ✅ Complete (including threading)
|
||||
- **Real-time Updates**: ✅ Complete (EventSource push, toast notifications, status indicator)
|
||||
- **UI Enhancements**: ✅ Settings fully integrated, drag-drop, context menus, mobile responsive, keyboard shortcuts
|
||||
- **Contacts/Address Book**: ❌ Not started
|
||||
|
||||
+83
-77
@@ -16,6 +16,89 @@ export default function LoginPage() {
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
|
||||
// All hooks must be called unconditionally at the top
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const justSelectedSuggestion = useRef(false);
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
if (serverUrl) {
|
||||
document.title = `${t('title')} - Webmail`;
|
||||
}
|
||||
}, [t, serverUrl]);
|
||||
|
||||
// Load saved usernames from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
if (saved) {
|
||||
try {
|
||||
const usernames = JSON.parse(saved);
|
||||
setSavedUsernames(usernames);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (formData.username && savedUsernames.length > 0) {
|
||||
const filtered = savedUsernames.filter(username =>
|
||||
username.toLowerCase().includes(formData.username.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else if (formData.username === "" && savedUsernames.length > 0) {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(false); // Don't show on empty input
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}, [formData.username, savedUsernames, serverUrl]);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
|
||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [serverUrl]);
|
||||
|
||||
// Show error if JMAP server URL is not configured
|
||||
if (!serverUrl) {
|
||||
return (
|
||||
@@ -33,37 +116,6 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = `${t('title')} - Webmail`;
|
||||
}, [t]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const justSelectedSuggestion = useRef(false);
|
||||
|
||||
// Load saved usernames from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
if (saved) {
|
||||
try {
|
||||
const usernames = JSON.parse(saved);
|
||||
setSavedUsernames(usernames);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save username on successful login
|
||||
const saveUsername = (username: string) => {
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
@@ -96,52 +148,6 @@ export default function LoginPage() {
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
}, [formData, clearError]);
|
||||
|
||||
// Filter suggestions based on input
|
||||
useEffect(() => {
|
||||
// Skip showing suggestions if we just selected one
|
||||
if (justSelectedSuggestion.current) {
|
||||
justSelectedSuggestion.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.username && savedUsernames.length > 0) {
|
||||
const filtered = savedUsernames.filter(username =>
|
||||
username.toLowerCase().includes(formData.username.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else if (formData.username === "" && savedUsernames.length > 0) {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(false); // Don't show on empty input
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}, [formData.username, savedUsernames]);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
|
||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
};
|
||||
|
||||
+97
-24
@@ -7,7 +7,9 @@ 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";
|
||||
@@ -34,6 +36,10 @@ export default function Home() {
|
||||
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();
|
||||
|
||||
@@ -555,10 +561,55 @@ export default function Home() {
|
||||
|
||||
// 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">
|
||||
@@ -628,6 +679,7 @@ export default function Home() {
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenConversation={handleOpenConversation}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
selectEmail(email);
|
||||
@@ -683,37 +735,58 @@ export default function Home() {
|
||||
"md:flex-1 md:relative"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Header for Viewer */}
|
||||
{isMobile && activeView === "viewer" && (
|
||||
<MobileViewerHeader
|
||||
subject={selectedEmail?.subject}
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
{isMobile && conversationThread ? (
|
||||
<ThreadConversationView
|
||||
thread={conversationThread}
|
||||
emails={conversationEmails}
|
||||
isLoading={isLoadingConversation}
|
||||
onBack={handleMobileBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
isLoading={isLoadingEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onReply={handleConversationReply}
|
||||
onReplyAll={handleConversationReplyAll}
|
||||
onForward={handleConversationForward}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<>
|
||||
{/* 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}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { EmailListItem } from "./email-list-item";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
|
||||
interface EmailListProps {
|
||||
@@ -17,6 +18,8 @@ interface EmailListProps {
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
// Mobile conversation view handler
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
// Context menu actions
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
@@ -35,6 +38,7 @@ export function EmailList({
|
||||
onEmailSelect,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
@@ -58,8 +62,19 @@ export function EmailList({
|
||||
isLoadingMore,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
expandedThreadIds,
|
||||
threadEmailsCache,
|
||||
isLoadingThread,
|
||||
toggleThreadExpansion,
|
||||
fetchThreadEmails,
|
||||
} = useEmailStore();
|
||||
|
||||
// Group emails by thread
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails]);
|
||||
|
||||
// Context menu state
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
|
||||
@@ -116,6 +131,20 @@ export function EmailList({
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
|
||||
// Handle thread expansion and fetch complete thread
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
|
||||
if (!isExpanded && client) {
|
||||
// Expanding - fetch complete thread emails
|
||||
toggleThreadExpansion(threadId);
|
||||
await fetchThreadEmails(client, threadId);
|
||||
} else {
|
||||
// Collapsing - just toggle
|
||||
toggleThreadExpansion(threadId);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -231,7 +260,7 @@ export function EmailList({
|
||||
)}
|
||||
</button>
|
||||
<h2 className="text-sm font-medium text-foreground">
|
||||
{isLoading ? 'Loading...' : emails.length > 0 ? `${emails.length} conversations` : 'No conversations'}
|
||||
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,13 +288,18 @@ export function EmailList({
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
|
||||
{emails.map((email) => (
|
||||
<EmailListItem
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={email.id === selectedEmailId}
|
||||
onClick={() => onEmailSelect?.(email)}
|
||||
{threadGroups.map((thread) => (
|
||||
<ThreadListItem
|
||||
key={thread.threadId}
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Star,
|
||||
Download,
|
||||
Loader2,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
File,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
emails: Email[];
|
||||
isLoading?: boolean;
|
||||
onBack: () => void;
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
}
|
||||
|
||||
// Helper function to get file icon based on mime type or extension
|
||||
const getFileIcon = (name?: string, type?: string) => {
|
||||
const ext = name?.split('.').pop()?.toLowerCase();
|
||||
const mimeType = type?.toLowerCase();
|
||||
|
||||
if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
|
||||
return FileImage;
|
||||
}
|
||||
if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
|
||||
return FileVideo;
|
||||
}
|
||||
if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
|
||||
return FileAudio;
|
||||
}
|
||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||
return FileText;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
|
||||
return FileArchive;
|
||||
}
|
||||
return File;
|
||||
};
|
||||
|
||||
export function ThreadConversationView({
|
||||
thread,
|
||||
emails,
|
||||
isLoading = false,
|
||||
onBack,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onDownloadAttachment,
|
||||
onMarkAsRead,
|
||||
}: ThreadConversationViewProps) {
|
||||
const t = useTranslations();
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
|
||||
// Track which emails are expanded (most recent by default)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
|
||||
|
||||
// Auto-expand most recent email AND all unread emails when thread opens
|
||||
useEffect(() => {
|
||||
if (emails.length > 0) {
|
||||
const idsToExpand = new Set<string>();
|
||||
|
||||
// Always expand most recent
|
||||
idsToExpand.add(emails[0].id);
|
||||
|
||||
// Also expand all unread emails
|
||||
emails.forEach(email => {
|
||||
if (!email.keywords?.$seen) {
|
||||
idsToExpand.add(email.id);
|
||||
}
|
||||
});
|
||||
|
||||
setExpandedIds(idsToExpand);
|
||||
}
|
||||
}, [emails]);
|
||||
|
||||
const toggleExpanded = (emailId: string) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(emailId)) {
|
||||
next.delete(emailId);
|
||||
} else {
|
||||
next.add(emailId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllowExternal = (emailId: string) => {
|
||||
setAllowExternalContent(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(emailId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t("threads.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-semibold text-foreground truncate">
|
||||
{thread.latestEmail.subject || t("email_viewer.no_subject")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("threads.messages_other", { count: emails.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Cards */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-3">
|
||||
{emails.map((email, index) => (
|
||||
<EmailCard
|
||||
key={email.id}
|
||||
email={email}
|
||||
isExpanded={expandedIds.has(email.id)}
|
||||
isLatest={index === 0}
|
||||
allowExternal={externalContentPolicy === 'allow' || allowExternalContent.has(email.id)}
|
||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||
onReply={onReply ? () => onReply(email) : undefined}
|
||||
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
|
||||
onForward={onForward ? () => onForward(email) : undefined}
|
||||
onDownloadAttachment={onDownloadAttachment}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual email card component
|
||||
interface EmailCardProps {
|
||||
email: Email;
|
||||
isExpanded: boolean;
|
||||
isLatest: boolean;
|
||||
allowExternal: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onAllowExternal: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
}
|
||||
|
||||
function EmailCard({
|
||||
email,
|
||||
isExpanded,
|
||||
isLatest: _isLatest,
|
||||
allowExternal,
|
||||
onToggleExpanded,
|
||||
onAllowExternal,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onDownloadAttachment,
|
||||
onMarkAsRead,
|
||||
}: EmailCardProps) {
|
||||
const t = useTranslations();
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
|
||||
// Mark as read when email is expanded
|
||||
useEffect(() => {
|
||||
// Only trigger if expanded, email is unread, and we have a handler
|
||||
if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
|
||||
// Never auto-mark
|
||||
if (markAsReadDelay === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mark
|
||||
if (markAsReadDelay === 0) {
|
||||
onMarkAsRead(email.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed mark
|
||||
const timeout = setTimeout(() => {
|
||||
onMarkAsRead(email.id, true);
|
||||
}, markAsReadDelay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false };
|
||||
|
||||
if (email.bodyValues) {
|
||||
let useHtmlVersion = false;
|
||||
let htmlContent = '';
|
||||
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
|
||||
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
|
||||
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
|
||||
|
||||
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
|
||||
}
|
||||
|
||||
if (useHtmlVersion && htmlContent) {
|
||||
let blockedExternalContent = false;
|
||||
|
||||
const sanitizeConfig = {
|
||||
ADD_TAGS: ['style'],
|
||||
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
|
||||
};
|
||||
|
||||
if (!allowExternal) {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||
node.setAttribute('data-blocked-src', src);
|
||||
node.removeAttribute('src');
|
||||
node.setAttribute('alt', '[Image blocked]');
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style');
|
||||
if (style && /url\s*\(/i.test(style)) {
|
||||
const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none');
|
||||
node.setAttribute('style', cleanStyle);
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||
|
||||
if (blockedExternalContent) {
|
||||
setHasBlockedContent(true);
|
||||
}
|
||||
|
||||
return { html: sanitized, isHtml: true };
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
const htmlEscaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to preview
|
||||
if (email.preview) {
|
||||
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
|
||||
isUnread && !isExpanded && "border-l-2 border-l-primary"
|
||||
)}>
|
||||
{/* Card Header - Always visible */}
|
||||
<button
|
||||
onClick={onToggleExpanded}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors",
|
||||
!isExpanded && "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={cn(
|
||||
"font-medium truncate",
|
||||
isUnread ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{isStarred && (
|
||||
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(email.receivedAt)}
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 p-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("email_viewer.external_content_warning")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllowExternal();
|
||||
}}
|
||||
>
|
||||
{t("email_viewer.load_external_content")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="px-4 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
{onReply && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Reply className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.reply")}
|
||||
</Button>
|
||||
)}
|
||||
{onReplyAll && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReplyAll();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ReplyAll className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.reply_all")}
|
||||
</Button>
|
||||
)}
|
||||
{onForward && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForward();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Forward className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.forward")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle } from "lucide-react";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
export function ThreadEmailItem({
|
||||
email,
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer transition-all duration-150",
|
||||
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
|
||||
"border-l-2 border-l-transparent",
|
||||
selected
|
||||
? "bg-accent border-l-primary"
|
||||
: "hover:bg-muted/50",
|
||||
isUnread && !selected && "bg-accent/20",
|
||||
!isLast && "border-b border-border/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Small Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Single line: Sender, indicators, preview, date */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"truncate text-sm flex-shrink-0 max-w-[150px]",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email?.split('@')[0] || "Unknown"}
|
||||
</span>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isStarred && (
|
||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
<span className={cn(
|
||||
"text-sm truncate flex-1 min-w-0",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70"
|
||||
)}>
|
||||
{email.preview || "No preview"}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getThreadColorTag } from "@/lib/thread-utils";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
selectedEmailId?: string;
|
||||
isLoading?: boolean;
|
||||
expandedEmails?: Email[]; // Full thread emails when expanded
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
|
||||
}
|
||||
|
||||
// Color tag mapping
|
||||
const colorTags = {
|
||||
red: "bg-red-50 dark:bg-red-950/30",
|
||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
green: "bg-green-50 dark:bg-green-950/30",
|
||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||
} as const;
|
||||
|
||||
export function ThreadListItem({
|
||||
thread,
|
||||
isExpanded,
|
||||
selectedEmailId,
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
}: ThreadListItemProps) {
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||
|
||||
// Get color tag from thread
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
|
||||
// Check if latest email is selected
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
// Single email thread - render as regular email, no expand
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
<SingleEmailItem
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get emails to display when expanded
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
// Mobile: open conversation view instead of inline expansion
|
||||
if (isMobile && onOpenConversation) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: If clicking directly on the expand icon area, toggle expansion
|
||||
// Otherwise, select the latest email
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-expand-toggle]')) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
// Clicking on the row selects the latest email but also expands
|
||||
if (!isExpanded) {
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, latestEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
{/* Thread Header (collapsed view) */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Expand/Collapse Button - Hidden on mobile */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Unread indicator */}
|
||||
{hasUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Participants and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
</span>
|
||||
{/* Email count badge */}
|
||||
<span className={cn(
|
||||
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Thread Emails - Desktop only */}
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : (
|
||||
emailsToShow.map((email, index) => (
|
||||
<ThreadEmailItem
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single email item (for threads with only 1 email)
|
||||
function SingleEmailItem({
|
||||
email,
|
||||
selected,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
showPreview,
|
||||
colorTag,
|
||||
}: {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Spacer for alignment with thread items */}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,22 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Threads Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.threads")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer tip */}
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface KeyboardShortcutHandlers {
|
||||
// Selection
|
||||
onSelectAll?: () => void;
|
||||
onDeselectAll?: () => void;
|
||||
|
||||
// Thread actions
|
||||
onToggleThreadExpansion?: () => void;
|
||||
}
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
@@ -199,6 +202,14 @@ export function useKeyboardShortcuts({
|
||||
h.onRefresh?.();
|
||||
}
|
||||
break;
|
||||
|
||||
// Thread actions
|
||||
case "x":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleThreadExpansion?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedEmailId]
|
||||
@@ -261,4 +272,7 @@ export const KEYBOARD_SHORTCUTS = {
|
||||
{ key: "Shift + G", description: "shortcuts.global.refresh" },
|
||||
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
+87
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -799,6 +799,92 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Thread methods for conversation view
|
||||
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const response = await this.request([
|
||||
["Thread/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: [threadId],
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||
const threads = response.methodResponses[0][1].list || [];
|
||||
return threads[0] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get thread:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
// First get the thread to find all email IDs
|
||||
const thread = await this.getThread(threadId, accountId);
|
||||
if (!thread || !thread.emailIds || thread.emailIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all emails in the thread
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: thread.emailIds,
|
||||
properties: [
|
||||
"id",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
||||
const emails = response.methodResponses[0][1].list || [];
|
||||
|
||||
// If fetching from a shared account, namespace the mailboxIds
|
||||
const isSharedAccount = accountId && accountId !== this.accountId;
|
||||
if (isSharedAccount) {
|
||||
emails.forEach((email: Email) => {
|
||||
if (email.mailboxIds) {
|
||||
const namespacedMailboxIds: Record<string, boolean> = {};
|
||||
Object.keys(email.mailboxIds).forEach(mbId => {
|
||||
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
|
||||
});
|
||||
email.mailboxIds = namespacedMailboxIds;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by receivedAt descending (newest first)
|
||||
return emails.sort((a: Email, b: Email) =>
|
||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get thread emails:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
to: string[],
|
||||
subject: string,
|
||||
|
||||
@@ -130,6 +130,18 @@ export interface Thread {
|
||||
emailIds: string[];
|
||||
}
|
||||
|
||||
// Thread grouping for UI display
|
||||
export interface ThreadGroup {
|
||||
threadId: string;
|
||||
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
|
||||
latestEmail: Email; // Most recent email
|
||||
participantNames: string[];// Unique participant names
|
||||
hasUnread: boolean; // Any unread emails in thread
|
||||
hasStarred: boolean; // Any starred emails in thread
|
||||
hasAttachment: boolean; // Any email has attachment
|
||||
emailCount: number; // Total emails in thread
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Email, ThreadGroup } from "./jmap/types";
|
||||
|
||||
/**
|
||||
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
|
||||
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
||||
*/
|
||||
export function groupEmailsByThread(emails: Email[]): ThreadGroup[] {
|
||||
if (!emails || emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group emails by threadId
|
||||
const threadMap = new Map<string, Email[]>();
|
||||
|
||||
for (const email of emails) {
|
||||
const threadId = email.threadId;
|
||||
if (!threadMap.has(threadId)) {
|
||||
threadMap.set(threadId, []);
|
||||
}
|
||||
threadMap.get(threadId)!.push(email);
|
||||
}
|
||||
|
||||
// Convert to ThreadGroup array
|
||||
const threadGroups: ThreadGroup[] = [];
|
||||
|
||||
for (const [threadId, threadEmails] of threadMap) {
|
||||
// Sort emails by receivedAt descending (newest first)
|
||||
const sortedEmails = [...threadEmails].sort(
|
||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
const latestEmail = sortedEmails[0];
|
||||
|
||||
// Collect unique participant names from all emails in thread
|
||||
const participantNames = getThreadParticipants(sortedEmails);
|
||||
|
||||
// Check for unread, starred, and attachments
|
||||
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
|
||||
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
|
||||
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
|
||||
|
||||
threadGroups.push({
|
||||
threadId,
|
||||
emails: sortedEmails,
|
||||
latestEmail,
|
||||
participantNames,
|
||||
hasUnread,
|
||||
hasStarred,
|
||||
hasAttachment,
|
||||
emailCount: sortedEmails.length,
|
||||
});
|
||||
}
|
||||
|
||||
return threadGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts thread groups by their latest email's receivedAt date (newest first).
|
||||
*/
|
||||
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
|
||||
return [...groups].sort(
|
||||
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique participant names from a list of emails.
|
||||
* Includes both senders and recipients, limited to avoid UI overflow.
|
||||
*/
|
||||
export function getThreadParticipants(emails: Email[], maxNames: number = 4): string[] {
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
||||
for (const email of emails) {
|
||||
// Add sender
|
||||
if (email.from && email.from.length > 0) {
|
||||
const sender = email.from[0];
|
||||
const senderName = sender.name || sender.email.split('@')[0];
|
||||
const key = sender.email.toLowerCase();
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
names.push(senderName);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if we have enough names
|
||||
if (names.length >= maxNames) break;
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges newly fetched thread emails into an existing thread group.
|
||||
* Used when expanding a thread to show all emails (some may not have been in the original list).
|
||||
*/
|
||||
export function mergeThreadEmails(
|
||||
existingGroup: ThreadGroup,
|
||||
fetchedEmails: Email[]
|
||||
): ThreadGroup {
|
||||
// Create a map of existing emails by ID
|
||||
const emailMap = new Map<string, Email>();
|
||||
|
||||
for (const email of existingGroup.emails) {
|
||||
emailMap.set(email.id, email);
|
||||
}
|
||||
|
||||
// Add fetched emails that aren't already in the group
|
||||
for (const email of fetchedEmails) {
|
||||
if (!emailMap.has(email.id)) {
|
||||
emailMap.set(email.id, email);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back to array and sort
|
||||
const mergedEmails = Array.from(emailMap.values()).sort(
|
||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
const latestEmail = mergedEmails[0];
|
||||
const participantNames = getThreadParticipants(mergedEmails);
|
||||
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
|
||||
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
|
||||
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
|
||||
|
||||
return {
|
||||
threadId: existingGroup.threadId,
|
||||
emails: mergedEmails,
|
||||
latestEmail,
|
||||
participantNames,
|
||||
hasUnread,
|
||||
hasStarred,
|
||||
hasAttachment,
|
||||
emailCount: mergedEmails.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets color tag from email keywords (if any).
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
if (!keywords) return null;
|
||||
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
return key.replace("$color:", "");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+18
-1
@@ -448,7 +448,8 @@
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Email Actions",
|
||||
"global": "Global"
|
||||
"global": "Global",
|
||||
"threads": "Threads"
|
||||
},
|
||||
"navigation": {
|
||||
"next_email": "Next email",
|
||||
@@ -472,6 +473,22 @@
|
||||
"help": "Show shortcuts",
|
||||
"refresh": "Refresh emails",
|
||||
"select_all": "Select all"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Expand/collapse thread"
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"messages_one": "{count} message",
|
||||
"messages_other": "{count} messages",
|
||||
"expand": "Expand conversation",
|
||||
"collapse": "Collapse conversation",
|
||||
"loading": "Loading conversation...",
|
||||
"mark_read": "Mark conversation as read",
|
||||
"mark_unread": "Mark conversation as unread",
|
||||
"archive": "Archive conversation",
|
||||
"delete": "Delete conversation",
|
||||
"star": "Star conversation",
|
||||
"unstar": "Unstar conversation"
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -448,7 +448,8 @@
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions email",
|
||||
"global": "Global"
|
||||
"global": "Global",
|
||||
"threads": "Conversations"
|
||||
},
|
||||
"navigation": {
|
||||
"next_email": "Email suivant",
|
||||
@@ -472,6 +473,22 @@
|
||||
"help": "Afficher les raccourcis",
|
||||
"refresh": "Actualiser les emails",
|
||||
"select_all": "Tout sélectionner"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Développer/réduire la conversation"
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"messages_one": "{count} message",
|
||||
"messages_other": "{count} messages",
|
||||
"expand": "Développer la conversation",
|
||||
"collapse": "Réduire la conversation",
|
||||
"loading": "Chargement de la conversation...",
|
||||
"mark_read": "Marquer la conversation comme lue",
|
||||
"mark_unread": "Marquer la conversation comme non lue",
|
||||
"archive": "Archiver la conversation",
|
||||
"delete": "Supprimer la conversation",
|
||||
"star": "Marquer la conversation comme favorite",
|
||||
"unstar": "Retirer des favoris"
|
||||
}
|
||||
}
|
||||
Generated
+446
@@ -33,6 +33,8 @@
|
||||
"eslint-config-next": "^16.0.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -2504,6 +2506,35 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
|
||||
"integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -2922,6 +2953,39 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^7.1.0",
|
||||
"string-width": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2957,6 +3021,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
||||
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3215,6 +3296,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
||||
@@ -3993,6 +4087,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4195,6 +4296,19 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -4439,6 +4553,22 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
@@ -4674,6 +4804,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -5353,6 +5499,49 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
|
||||
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"listr2": "^9.0.5",
|
||||
"micromatch": "^4.0.8",
|
||||
"nano-spawn": "^2.0.0",
|
||||
"pidtree": "^0.6.0",
|
||||
"string-argv": "^0.3.2",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/lint-staged"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-truncate": "^5.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"log-update": "^6.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5376,6 +5565,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -5454,6 +5663,19 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -5487,6 +5709,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
|
||||
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -5878,6 +6113,22 @@
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-function": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
@@ -6020,6 +6271,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pidtree": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
|
||||
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"pidtree": "bin/pidtree.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz",
|
||||
@@ -6262,6 +6526,23 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^7.0.0",
|
||||
"signal-exit": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -6273,6 +6554,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -6564,6 +6852,49 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6594,6 +6925,33 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.19"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -6707,6 +7065,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -7299,12 +7673,84 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+5
-1
@@ -7,7 +7,9 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix"
|
||||
"lint:fix": "next lint --fix",
|
||||
"prepare": "husky",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -35,6 +37,8 @@
|
||||
"eslint-config-next": "^16.0.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
+88
-1
@@ -22,6 +22,11 @@ interface EmailStore {
|
||||
lastPushUpdate: number | null; // Timestamp of last push update
|
||||
newEmailNotification: Email | null; // New email notification for toast
|
||||
|
||||
// Thread expansion state
|
||||
expandedThreadIds: Set<string>; // Which threads are expanded in the list
|
||||
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
|
||||
isLoadingThread: string | null; // Thread ID currently being loaded
|
||||
|
||||
setEmails: (emails: Email[]) => void;
|
||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||
selectEmail: (email: Email | null) => void;
|
||||
@@ -60,6 +65,12 @@ interface EmailStore {
|
||||
handleNewEmailNotification: (email: Email) => void;
|
||||
clearNewEmailNotification: () => void;
|
||||
|
||||
// Thread expansion actions
|
||||
toggleThreadExpansion: (threadId: string) => void;
|
||||
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
|
||||
collapseAllThreads: () => void;
|
||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||
|
||||
// Mock data for demo
|
||||
loadMockData: () => void;
|
||||
}
|
||||
@@ -83,10 +94,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
lastPushUpdate: null,
|
||||
newEmailNotification: null,
|
||||
|
||||
// Thread expansion state
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
isLoadingThread: null,
|
||||
|
||||
setEmails: (emails) => set({ emails }),
|
||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||||
selectEmail: (email) => set({ selectedEmail: email }),
|
||||
selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set() }),
|
||||
selectMailbox: (mailboxId) => set({
|
||||
selectedMailbox: mailboxId,
|
||||
selectedEmail: null,
|
||||
selectedEmailIds: new Set(),
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
isLoadingThread: null,
|
||||
}),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
@@ -795,6 +818,70 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ newEmailNotification: null });
|
||||
},
|
||||
|
||||
// Thread expansion actions
|
||||
toggleThreadExpansion: (threadId) => {
|
||||
const { expandedThreadIds } = get();
|
||||
const newExpandedThreadIds = new Set(expandedThreadIds);
|
||||
|
||||
if (newExpandedThreadIds.has(threadId)) {
|
||||
newExpandedThreadIds.delete(threadId);
|
||||
} else {
|
||||
newExpandedThreadIds.add(threadId);
|
||||
}
|
||||
|
||||
set({ expandedThreadIds: newExpandedThreadIds });
|
||||
},
|
||||
|
||||
fetchThreadEmails: async (client, threadId) => {
|
||||
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
|
||||
|
||||
// Check if we already have this thread cached
|
||||
const cachedEmails = threadEmailsCache.get(threadId);
|
||||
if (cachedEmails && cachedEmails.length > 0) {
|
||||
return cachedEmails;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
set({ isLoadingThread: threadId });
|
||||
|
||||
try {
|
||||
// Determine accountId for shared folders
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
// Fetch all emails in the thread
|
||||
const emails = await client.getThreadEmails(threadId, accountId);
|
||||
|
||||
// Update cache
|
||||
const newCache = new Map(get().threadEmailsCache);
|
||||
newCache.set(threadId, emails);
|
||||
|
||||
set({
|
||||
threadEmailsCache: newCache,
|
||||
isLoadingThread: null
|
||||
});
|
||||
|
||||
return emails;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
set({ isLoadingThread: null });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
collapseAllThreads: () => {
|
||||
set({
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null
|
||||
});
|
||||
},
|
||||
|
||||
updateThreadCache: (threadId, emails) => {
|
||||
const newCache = new Map(get().threadEmailsCache);
|
||||
newCache.set(threadId, emails);
|
||||
set({ threadEmailsCache: newCache });
|
||||
},
|
||||
|
||||
loadMockData: () => {
|
||||
const mockEmails: Email[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user