From 439a4dbe8ae9cc3fec0c8d9feb675b1718eee207 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:31:42 +0100 Subject: [PATCH] feat: add hover actions for emails and update settings for quick actions --- components/email/email-hover-actions.tsx | 138 +++++++++++++++++++++++ components/email/email-list-item.tsx | 22 +++- components/email/email-list.tsx | 6 + components/email/thread-list-item.tsx | 53 ++++++++- components/settings/email-settings.tsx | 38 ++++++- locales/en/common.json | 11 ++ stores/settings-store.ts | 14 +++ 7 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 components/email/email-hover-actions.tsx diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx new file mode 100644 index 00000000..76c0cc52 --- /dev/null +++ b/components/email/email-hover-actions.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { Email } from "@/lib/jmap/types"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { HoverAction } from "@/stores/settings-store"; +import { cn } from "@/lib/utils"; +import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; + +interface EmailHoverActionsProps { + email: Email; + onToggleStar?: () => void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; +} + +const ACTION_CONFIG: Record = { + delete: { + icon: Trash2, + titleKey: "delete", + className: "hover:text-red-600 dark:hover:text-red-400", + }, + star: { + icon: Star, + titleKey: "star", + className: "hover:text-amber-500 dark:hover:text-amber-400", + }, + markRead: { + icon: Mail, + titleKey: "mark_read", + className: "hover:text-blue-600 dark:hover:text-blue-400", + }, + archive: { + icon: Archive, + titleKey: "archive", + className: "hover:text-green-600 dark:hover:text-green-400", + }, + tag: { + icon: Tag, + titleKey: "tag", + className: "hover:text-purple-600 dark:hover:text-purple-400", + }, + spam: { + icon: ShieldAlert, + titleKey: "spam", + className: "hover:text-orange-600 dark:hover:text-orange-400", + }, +}; + +export function EmailHoverActions({ + email, + onToggleStar, + onMarkAsRead, + onDelete, + onArchive, + onSetColorTag, + onMarkAsSpam, +}: EmailHoverActionsProps) { + const hoverActions = useSettingsStore((state) => state.hoverActions); + const t = useTranslations("settings.email_behavior.hover_actions"); + + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + + if (hoverActions.length === 0) return null; + + const handleAction = (e: React.MouseEvent, action: HoverAction) => { + e.stopPropagation(); + e.preventDefault(); + switch (action) { + case "delete": + onDelete?.(); + break; + case "star": + onToggleStar?.(); + break; + case "markRead": + onMarkAsRead?.(!isUnread); + break; + case "archive": + onArchive?.(); + break; + case "tag": + onSetColorTag?.(null); + break; + case "spam": + onMarkAsSpam?.(); + break; + } + }; + + return ( +
+
+
+ {hoverActions.map((actionId) => { + const config = ACTION_CONFIG[actionId]; + if (!config) return null; + const Icon = config.icon; + + const DisplayIcon = actionId === "markRead" + ? (isUnread ? MailOpen : Mail) + : actionId === "star" && isStarred + ? Star + : Icon; + + return ( + + ); + })} +
+
+ ); +} diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 7aec12fa..2b69a663 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { useUIStore } from "@/stores/ui-store"; import { EmailIdentityBadge } from "./email-identity-badge"; +import { EmailHoverActions } from "./email-hover-actions"; import { getEmailColorTag } from "@/lib/thread-utils"; interface EmailListItemProps { @@ -21,9 +22,15 @@ interface EmailListItemProps { selected?: boolean; onClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; + onToggleStar?: () => void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; } -export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) { +export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) { const t = useTranslations('email_viewer'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); @@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email {...dragHandlers} {...longPressHandlers} className={cn( - "relative group cursor-pointer select-none transition-all duration-200 border-b border-border", + "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", // Apply color tag as background, with selected and unread states colorTag ? colorTag : ( selected @@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email )}
+ + {/* Hover Quick Actions */} + ); } \ No newline at end of file diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 693b5175..47248399 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -422,6 +422,12 @@ export function EmailList({ onEmailSelect={(email) => onEmailSelect?.(email)} onContextMenu={openContextMenu} onOpenConversation={onOpenConversation} + onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} + onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} + onDelete={onDelete ? (email) => onDelete(email) : undefined} + onArchive={onArchive ? (email) => onArchive(email) : undefined} + onSetColorTag={onSetColorTag} + onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} /> ); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 28ae8262..311c13cf 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; +import { EmailHoverActions } from "./email-hover-actions"; import { useTranslations } from "next-intl"; interface ThreadListItemProps { @@ -25,6 +26,12 @@ interface ThreadListItemProps { onEmailSelect: (email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; onOpenConversation?: (thread: ThreadGroup) => void; + onToggleStar?: (email: Email) => void; + onMarkAsRead?: (email: Email, read: boolean) => void; + onDelete?: (email: Email) => void; + onArchive?: (email: Email) => void; + onSetColorTag?: (emailId: string, color: string | null) => void; + onMarkAsSpam?: (email: Email) => void; } interface SingleEmailItemProps { @@ -34,10 +41,16 @@ interface SingleEmailItemProps { onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; colorTag: string | null; + onToggleStar?: () => void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) { + function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const sender = email.from?.[0]; @@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...longPressHandlers} className={cn( - "relative group cursor-pointer select-none transition-all duration-200 border-b border-border", + "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", resolvedColorTag ? resolvedColorTag : ( selected ? "bg-accent" @@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef( )} + + {/* Hover Quick Actions */} + ); } @@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef state.showPreview); @@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} + onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} + onDelete={onDelete ? () => onDelete(latestEmail) : undefined} + onArchive={onArchive ? () => onArchive(latestEmail) : undefined} + onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} /> ); } @@ -339,7 +375,7 @@ export const ThreadListItem = React.forwardRef + + {/* Hover Quick Actions for thread header */} + onToggleStar(latestEmail) : undefined} + onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} + onDelete={onDelete ? () => onDelete(latestEmail) : undefined} + onArchive={onArchive ? () => onArchive(latestEmail) : undefined} + onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} + /> {isExpanded && !isMobile && ( diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index ed74e35e..6bd31dea 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -3,9 +3,11 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; -import type { ArchiveMode } from '@/stores/settings-store'; +import type { ArchiveMode, HoverAction } from '@/stores/settings-store'; +import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; +import { cn } from '@/lib/utils'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react'; @@ -27,6 +29,7 @@ export function EmailSettings() { attachmentPosition, emailAlwaysLightMode, archiveMode, + hoverActions, trustedSenders, updateSetting, } = useSettingsStore(); @@ -185,6 +188,39 @@ export function EmailSettings() { updateSetting('showPreview', checked)} /> + {/* Quick Hover Actions */} +
+
+ +

{t('hover_actions.description')}

+
+
+ {ALL_HOVER_ACTIONS.map((action) => { + const isEnabled = hoverActions.includes(action.id); + return ( + + ); + })} +
+
+