diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0e9a6ee3..7f57ae21 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -830,16 +830,22 @@ export default function Home() { const keywords = { ...email.keywords }; - // Remove old label and legacy color tags - set to false for JMAP to remove them - Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { - keywords[key] = false; + if (color === null) { + // Remove all label/color tags + Object.keys(keywords).forEach(key => { + if (key.startsWith("$label:") || key.startsWith("$color:")) { + keywords[key] = false; + } + }); + } else { + const jmapKey = `$label:${color}`; + if (keywords[jmapKey] === true) { + // Toggle off if already active + keywords[jmapKey] = false; + } else { + // Add the tag without disturbing others + keywords[jmapKey] = true; } - }); - - // Add new label tag if specified (using new $label: prefix) - if (color) { - keywords[`$label:${color}`] = true; } // Update email keywords via JMAP diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 1e5e93d2..983fd526 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => { } }; -// Get current label/color from email keywords (supports both $label: and legacy $color:) -const getCurrentColor = (keywords: Record | undefined) => { - if (!keywords) return null; +// Get all active label/color tag IDs from email keywords +const getCurrentColors = (keywords: Record | undefined): string[] => { + if (!keywords) return []; + const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - return key.startsWith("$label:") - ? key.slice("$label:".length) - : key.slice("$color:".length); + tags.push( + key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) + ); } } - return null; + return tags; }; export function EmailContextMenu({ @@ -137,7 +138,7 @@ export function EmailContextMenu({ const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isDraft = email.keywords?.['$draft'] === true; - const currentColor = getCurrentColor(email.keywords); + const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; @@ -306,24 +307,27 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <> k.id === colorTagId) : null; + // Resolve color tags using keyword definitions from settings + const colorTagIds = getEmailColorTags(email.keywords); + const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + // Use first tag for background coloring + const keywordDef = keywordDefs[0] ?? null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; // Drag and drop functionality @@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl )} {email.hasAttachment && } - {keywordDef && } + {keywordDefs.map((kd) => ( + + ))}
- {keywordDef && ( - ( + - - {keywordDef.label} + + {kd.label} - )} + ))} | undefined) => { - if (!keywords) return null; +const getCurrentColors = (keywords: Record | undefined): string[] => { + if (!keywords) return []; + const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - return key.startsWith("$label:") - ? key.slice("$label:".length) - : key.slice("$color:".length); + tags.push( + key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) + ); } } - return null; + return tags; }; // Helper function to format recipients with contextual display @@ -933,7 +934,8 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColor = getCurrentColor(email?.keywords); + const currentColors = getCurrentColors(email?.keywords); + const currentColor = currentColors[0] ?? null; // S/MIME state const [smimeStatus, setSmimeStatus] = useState(null); @@ -3055,43 +3057,51 @@ export function EmailViewer({ onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} className={cn( "h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", - currentColor && "bg-muted/50" + currentColors.length > 0 && "bg-muted/50" )} title={t('set_color')} > - {(() => { - const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - <> - - {showToolbarLabels && {kw!.label}} - - ) : ( - <> - - {showToolbarLabels && {t('tag')}} - - ); - })()} + {currentColors.length > 0 ? ( + <> + + {currentColors.slice(0, 3).map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + return kw ? : null; + })} + + {showToolbarLabels && currentColors.length === 1 && ( + + {emailKeywords.find(k => k.id === currentColors[0])?.label} + + )} + + ) : ( + <> + + {showToolbarLabels && {t('tag')}} + + )} {tagMenuOpen && (
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( )} - {/* Color tag dot */} - {currentColor && (() => { - const kw = emailKeywords.find(k => k.id === currentColor); - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - - ) : null; - })()} + {/* Color tag dots */} + {currentColors.length > 0 && ( + + {currentColors.map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; + return dotClass ? ( + + ) : null; + })} + + )} {isImportant && ( {t('important')} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f0153957..9ee8e705 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; -import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; +import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color and keyword definition from keyword definitions if not passed directly - const tagId = getEmailColorTag(email.keywords); - const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; + // Resolve color tags using keyword definitions + const tagIds = getEmailColorTags(email.keywords); + const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedColorTag = (() => { if (colorTag) return colorTag; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; @@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef( )} {email.hasAttachment && } - {resolvedKeywordDef && } + {resolvedKeywordDefs.map((kd) => ( + + ))} (
- {resolvedKeywordDef && ( - ( + - - {resolvedKeywordDef.label} + + {kd.label} - )} + ))} )} {hasAttachment && } - {keywordDef && } + {keywordDef && ( + + )} void; onDelete: () => void; + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: () => void; + onDragEnd: () => void; + isDragOver: boolean; + isDragging: boolean; }) { const t = useTranslations("settings.keywords"); const palette = KEYWORD_PALETTE[keyword.color]; return ( -
+
{keyword.label} @@ -166,9 +189,39 @@ export function KeywordSettings() { const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); const [isMigrating, setIsMigrating] = useState(false); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const existingIds = emailKeywords.map((k) => k.id); + const handleDragStart = (index: number) => { + setDragIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (index !== dragOverIndex) setDragOverIndex(index); + }; + + const handleDrop = (index: number) => { + if (dragIndex === null || dragIndex === index) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const reordered = [...emailKeywords]; + const [moved] = reordered.splice(dragIndex, 1); + reordered.splice(index, 0, moved); + reorderKeywords(reordered); + setDragIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDragOverIndex(null); + }; + const handleAdd = (keyword: KeywordDefinition) => { addKeyword(keyword); setIsAdding(false); @@ -220,7 +273,7 @@ export function KeywordSettings() { {t("migrating")}
)} - {emailKeywords.map((keyword) => + {emailKeywords.map((keyword, index) => editingId === keyword.id ? ( handleDelete(keyword.id)} + onDragStart={() => handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + isDragOver={dragOverIndex === index && dragIndex !== index} + isDragging={dragIndex === index} /> ) )} diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index c90c68c1..61e7a391 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us const email = currentEmails.find(em => em.id === emailId); const keywords = { ...(email?.keywords || {}) }; - // Remove old label/color keywords - Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { - keywords[key] = false; - } - }); - - // Add the new tag + // Add the tag without removing existing ones keywords[`$label:${tagId}`] = true; await client.updateEmailKeywords(emailId, keywords); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 68b53f10..65dfbae0 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:"; export const KEYWORD_PREFIX_LEGACY = "$color:"; /** - * Gets label/color tag from email keywords (if any). + * Gets all active label/color tag IDs from email keywords. * Reads both the current $label: prefix and the legacy $color: prefix. */ -export function getEmailColorTag(keywords: Record | undefined): string | null { - if (!keywords) return null; - +export function getEmailColorTags(keywords: Record | undefined): string[] { + if (!keywords) return []; + const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { - return key.startsWith(KEYWORD_PREFIX) - ? key.slice(KEYWORD_PREFIX.length) - : key.slice(KEYWORD_PREFIX_LEGACY.length); + tags.push( + key.startsWith(KEYWORD_PREFIX) + ? key.slice(KEYWORD_PREFIX.length) + : key.slice(KEYWORD_PREFIX_LEGACY.length) + ); } } + return tags; +} - return null; +/** + * Gets label/color tag from email keywords (if any). + * Reads both the current $label: prefix and the legacy $color: prefix. + * @deprecated Use getEmailColorTags for multi-tag support. + */ +export function getEmailColorTag(keywords: Record | undefined): string | null { + const tags = getEmailColorTags(keywords); + return tags.length > 0 ? tags[0] : null; } /**