feat: implement email keyword management with color tagging
- Introduced a new settings section for managing email keywords (labels/tags) with associated colors. - Updated email context menu, viewer, and list item components to support the new keyword system. - Refactored color tag handling to accommodate both new $label: and legacy $color: prefixes. - Added utility functions for retrieving email color tags based on keywords. - Enhanced localization files to include translations for the new keyword management features. - Updated the settings store to manage keyword definitions, including add, update, delete, and reorder functionalities. - Added tests for the new keyword handling logic.
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
Trash2,
|
||||
Archive,
|
||||
FolderInput,
|
||||
Palette,
|
||||
Tag,
|
||||
X,
|
||||
Inbox,
|
||||
Send,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -84,12 +85,14 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get current color from email keywords
|
||||
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
return key.replace("$color:", "");
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -125,22 +128,19 @@ export function EmailContextMenu({
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const currentColor = getCurrentColor(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
|
||||
// Color options for email tags (using translations)
|
||||
const colorOptions = [
|
||||
{ name: tColor("red"), value: "red", color: "bg-red-500" },
|
||||
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
|
||||
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
|
||||
{ name: tColor("green"), value: "green", color: "bg-green-500" },
|
||||
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
|
||||
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
|
||||
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
|
||||
];
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||
}));
|
||||
|
||||
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
|
||||
const moveTargets = mailboxes.filter(
|
||||
@@ -272,7 +272,7 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Set color submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
<div
|
||||
className="px-3 py-2 flex flex-wrap gap-2"
|
||||
role="group"
|
||||
|
||||
@@ -7,10 +7,11 @@ import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
@@ -19,39 +20,22 @@ interface EmailListItemProps {
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
// Color tag mapping - using lighter backgrounds for better readability
|
||||
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;
|
||||
|
||||
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
const color = key.replace("$color:", "");
|
||||
return colorTags[color as keyof typeof colorTags] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const sender = email.from?.[0];
|
||||
const colorTag = getEmailColor(email.keywords);
|
||||
|
||||
// Resolve color tag using keyword definitions from settings
|
||||
const colorTagId = getEmailColorTag(email.keywords);
|
||||
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -113,8 +113,10 @@ const getFileIcon = (name?: string, type?: string) => {
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
return key.replace("$color:", "");
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
return key.startsWith("$label:")
|
||||
? key.slice("$label:".length)
|
||||
: key.slice("$color:".length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -184,20 +186,17 @@ export function EmailViewer({
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
|
||||
// Color options for email tags (using translations)
|
||||
const colorOptions = [
|
||||
{ name: t("color_tag.red"), value: "red", color: "bg-red-500" },
|
||||
{ name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" },
|
||||
{ name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" },
|
||||
{ name: t("color_tag.green"), value: "green", color: "bg-green-500" },
|
||||
{ name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" },
|
||||
{ name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" },
|
||||
{ name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" },
|
||||
];
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||
}));
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet } = useDeviceDetection();
|
||||
@@ -804,17 +803,13 @@ export function EmailViewer({
|
||||
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
||||
title={t('set_color')}
|
||||
>
|
||||
<Circle className={cn(
|
||||
"w-4 h-4",
|
||||
currentColor === 'red' && "fill-red-500 text-red-500",
|
||||
currentColor === 'orange' && "fill-orange-500 text-orange-500",
|
||||
currentColor === 'yellow' && "fill-yellow-500 text-yellow-500",
|
||||
currentColor === 'green' && "fill-green-500 text-green-500",
|
||||
currentColor === 'blue' && "fill-blue-500 text-blue-500",
|
||||
currentColor === 'purple' && "fill-purple-500 text-purple-500",
|
||||
currentColor === 'pink' && "fill-pink-500 text-pink-500",
|
||||
!currentColor && "text-gray-400"
|
||||
)} />
|
||||
{(() => {
|
||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass
|
||||
? <div className={cn("w-4 h-4 rounded-full", dotClass)} />
|
||||
: <Circle className="w-4 h-4 text-gray-400" />;
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{/* Colors appear on hover */}
|
||||
|
||||
@@ -6,10 +6,10 @@ 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, MessageSquare } from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { getThreadColorTag } from "@/lib/thread-utils";
|
||||
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -26,16 +26,6 @@ interface ThreadListItemProps {
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
@@ -51,8 +41,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
|
||||
// Resolve color from keyword definitions if not passed directly
|
||||
const resolvedColorTag = (() => {
|
||||
if (colorTag) return colorTag;
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
const kw = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
||||
return kw ? KEYWORD_PALETTE[kw.color]?.bg ?? null : null;
|
||||
})();
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
@@ -80,15 +79,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{...dragHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
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",
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
@@ -192,7 +191,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
});
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
Reference in New Issue
Block a user