feat: improve visualization of tags

Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 013ef7d557
commit 108406a885
46 changed files with 1102 additions and 832 deletions
+55 -153
View File
@@ -12,7 +12,9 @@ import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { TagOptionLabel } from "./tag-option-label";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
@@ -76,7 +78,7 @@ import {
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -117,7 +119,7 @@ interface EmailViewerProps {
onArchive?: () => void;
onToggleStar?: () => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
@@ -202,7 +204,7 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
const getCurrentTagIds = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
@@ -630,7 +632,7 @@ export function EmailViewer({
onArchive,
onToggleStar,
onMarkAsRead,
onSetColorTag,
onSetTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
@@ -669,7 +671,7 @@ export function EmailViewer({
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName, tagNameCandidates } = useKeywordFormat();
const { sortTagIds, tagColor } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -712,12 +714,6 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
candidates: tagNameCandidates(kw.id),
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
}));
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
@@ -822,8 +818,13 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
const currentTagIds = getCurrentTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
// The header spans the reading pane, so it measures its own width rather than
// inheriting the message list's answer.
const headerTagsRef = useRef<HTMLDivElement>(null);
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
const currentColor = currentTagIds[0] ?? null;
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
@@ -1021,7 +1022,7 @@ export function EmailViewer({
showToolbarLabels,
isLoading,
moveTree.length,
colorOptions.length,
emailKeywords.length,
currentColor,
isInJunkFolder,
isTablet,
@@ -3000,65 +3001,19 @@ export function EmailViewer({
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
>
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<TagOptionLabel
candidates={tagNameCandidates(currentColors[0])}
className="max-w-40 text-xs font-medium text-foreground"
/>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
</button>
{tagMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }}
/>
</div>
)}
</div>
@@ -3250,7 +3205,7 @@ export function EmailViewer({
</div>
)}
{/* Overflow: tag - submenu */}
{colorOptions.length > 0 && (
{emailKeywords.length > 0 && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
@@ -3264,36 +3219,12 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/>
</div>
)}
</div>
@@ -3437,19 +3368,21 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
{emailKeywords.length > 0 && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
{currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
{sortedTagIds.slice(0, 3).map((tagId) => (
<span
key={tagId}
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
@@ -3545,35 +3478,13 @@ export function EmailViewer({
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</>
{moreMenuSub === 'tag' && (
<TagPicker
touch
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/>
)}
</div>
</div>
@@ -3631,28 +3542,19 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span
key={tagId}
className={cn("w-2.5 h-2.5 rounded-full shrink-0", dotClass)}
title={tagName(tagId)}
/>
);
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
</div>
{sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => (
<TagBadge key={tagId} tagId={tagId} variant={headerTagVariant} />
))}
</div>
)}
</div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-end">