feat: Make it easier to handle multiple tags

- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 108406a885
commit d9d9f91a86
11 changed files with 221 additions and 108 deletions
+11 -6
View File
@@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label"; import { localizeMailboxName } from "@/lib/mailbox-label";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { import {
ErrorBoundary, ErrorBoundary,
SidebarErrorFallback, SidebarErrorFallback,
@@ -1870,18 +1871,22 @@ export default function Home() {
if (tagId === null) { if (tagId === null) {
// Remove all tag keywords // Remove all tag keywords
Object.keys(keywords).forEach(key => { Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) { if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
keywords[key] = false; keywords[key] = false;
} }
}); });
} else { } else {
const jmapKey = `$label:${tagId}`; // Both prefixes name the same tag when read, so taking one off has to
if (keywords[jmapKey]) { // clear whichever spellings are actually set.
// Toggle off if already active const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
keywords[jmapKey] = false; .filter(key => keywords[key]);
if (activeKeys.length > 0) {
activeKeys.forEach(key => {
keywords[key] = false;
});
} else { } else {
// Add the tag without disturbing others // Add the tag without disturbing others
keywords[jmapKey] = true; keywords[KEYWORD_PREFIX + tagId] = true;
} }
} }
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagBadge } from '../tag-badge';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
];
describe('TagBadge', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names the tag by its full path', () => {
render(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
+21 -5
View File
@@ -53,12 +53,28 @@ describe('TagPicker', () => {
expect(onToggle).toHaveBeenCalledWith('work/clients'); expect(onToggle).toHaveBeenCalledWith('work/clients');
}); });
it('offers the clear-all row only while something is applied', () => { it('lists a tag it has no definition for, so it can be taken off', () => {
const { rerender } = render(<TagPicker selectedIds={[]} onToggle={() => {}} onClearAll={() => {}} />); const onToggle = vi.fn();
expect(screen.queryByText('remove_tag')).not.toBeInTheDocument(); const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
rerender(<TagPicker selectedIds={['work']} onToggle={() => {}} onClearAll={() => {}} />); const row = screen.getByText('from-elsewhere').closest('button')!;
expect(screen.getByText('remove_tag')).toBeInTheDocument(); expect(row).toHaveAttribute('aria-checked', 'true');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
// Nothing but the message says it exists, so deselecting is the last of it.
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
});
it('counts undefined tags towards the filter box, and matches them', () => {
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
expect(within(container).getByText('stray-3')).toBeInTheDocument();
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
}); });
it('hides the filter box until the list is long enough to need one', () => { it('hides the filter box until the list is long enough to need one', () => {
+3 -17
View File
@@ -36,6 +36,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label"; import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker"; import { TagPicker } from "./tag-picker";
interface Position { interface Position {
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
/** Every tag id set on a message, reading the current prefix and the legacy one. */
const getCurrentTagIds = (keywords: Record<string, boolean> | 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) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
export function EmailContextMenu({ export function EmailContextMenu({
email, email,
position, position,
@@ -155,7 +142,7 @@ export function EmailContextMenu({
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true; const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentTagIds = getCurrentTagIds(email.keywords); const currentTagIds = getEmailTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action // Marking your own outgoing mail as spam makes no sense - hide the action
@@ -373,8 +360,7 @@ export function EmailContextMenu({
<div className="w-56 max-w-[18rem]"> <div className="w-56 max-w-[18rem]">
<TagPicker <TagPicker
selectedIds={currentTagIds} selectedIds={currentTagIds}
onToggle={(tagId) => handleAction(() => onSetTag?.(tagId))} onToggle={(tagId) => onSetTag?.(tagId)}
onClearAll={() => handleAction(() => onSetTag?.(null))}
/> />
</div> </div>
</ContextMenuSubMenu> </ContextMenuSubMenu>
+27 -19
View File
@@ -133,6 +133,14 @@ export function EmailList({
}, [emails, disableThreading, isScheduledView, threadEmailCounts]); }, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
/**
* The row the menu was opened on, as the list currently has it. The menu holds
* the message it was handed when it opened, but tags can be applied from
* inside it without dismissing it, so what it draws has to keep up.
*/
const contextMenuEmail = contextMenu.data
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
: null;
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@@ -574,9 +582,9 @@ export function EmailList({
</div> </div>
{/* Context Menu */} {/* Context Menu */}
{contextMenu.data && ( {contextMenuEmail && (
<EmailContextMenu <EmailContextMenu
email={contextMenu.data} email={contextMenuEmail}
position={contextMenu.position} position={contextMenu.position}
isOpen={contextMenu.isOpen} isOpen={contextMenu.isOpen}
onClose={closeContextMenu} onClose={closeContextMenu}
@@ -584,25 +592,25 @@ export function EmailList({
mailboxes={mailboxes} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole} currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)} isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
selectedCount={selectedEmailIds.size} selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)} onReply={() => onReply?.(contextMenuEmail!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)} onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
onForward={() => onForward?.(contextMenu.data!)} onForward={() => onForward?.(contextMenuEmail!)}
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)} onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)} onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)} onDelete={() => onDelete?.(contextMenuEmail!)}
onArchive={() => onArchive?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenuEmail!)}
onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)} onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined} onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined} onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined} onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => { onBatchArchive={async () => {
+13 -23
View File
@@ -16,6 +16,7 @@ import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker"; import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source"; import { generateEmailSource } from "@/lib/email-source";
@@ -204,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentTagIds = (keywords: Record<string, boolean> | 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) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
// Helper function to format recipients with contextual display // Helper function to format recipients with contextual display
const _formatRecipients = ( const _formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined, recipients: Array<{ name?: string; email: string }> | undefined,
@@ -818,7 +806,7 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentTagIds = getCurrentTagIds(email?.keywords); const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds); const sortedTagIds = sortTagIds(currentTagIds);
// The header spans the reading pane, so it measures its own width rather than // The header spans the reading pane, so it measures its own width rather than
// inheriting the message list's answer. // inheriting the message list's answer.
@@ -3011,8 +2999,7 @@ export function EmailViewer({
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10"> <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 <TagPicker
selectedIds={currentTagIds} selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }} onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }}
/> />
</div> </div>
)} )}
@@ -3205,7 +3192,7 @@ export function EmailViewer({
</div> </div>
)} )}
{/* Overflow: tag - submenu */} {/* Overflow: tag - submenu */}
{emailKeywords.length > 0 && ( {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")} <div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')} onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)} onMouseLeave={() => setMoreMenuSub(null)}
@@ -3222,8 +3209,7 @@ export function EmailViewer({
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10"> <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 <TagPicker
selectedIds={currentTagIds} selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/> />
</div> </div>
)} )}
@@ -3368,7 +3354,7 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')} {isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button> </button>
{/* Tag (opens sub-view) */} {/* Tag (opens sub-view) */}
{emailKeywords.length > 0 && ( {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button <button
onClick={() => setMoreMenuSub('tag')} 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" className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
@@ -3482,8 +3468,7 @@ export function EmailViewer({
<TagPicker <TagPicker
touch touch
selectedIds={currentTagIds} selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/> />
)} )}
</div> </div>
@@ -3551,7 +3536,12 @@ export function EmailViewer({
{sortedTagIds.length > 0 && ( {sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1"> <div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => ( {sortedTagIds.map((tagId) => (
<TagBadge key={tagId} tagId={tagId} variant={headerTagVariant} /> <TagBadge
key={tagId}
tagId={tagId}
variant={headerTagVariant}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))} ))}
</div> </div>
)} )}
+24 -3
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text"; import { useShortenedText } from "@/hooks/use-shortened-text";
@@ -38,12 +40,19 @@ export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
export function TagBadge({ export function TagBadge({
tagId, tagId,
variant, variant,
onRemove,
className, className,
}: { }: {
tagId: string; tagId: string;
variant: TagBadgeVariant; variant: TagBadgeVariant;
/**
* Takes the tag off the message. Only the named form offers it - a dot is the
* size of the control it would have to hold.
*/
onRemove?: () => void;
className?: string; className?: string;
}) { }) {
const t = useTranslations("email_viewer");
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
const color = tagColor(tagId); const color = tagColor(tagId);
@@ -61,10 +70,9 @@ export function TagBadge({
return ( return (
<span <span
ref={labelRef}
className={cn( className={cn(
TAG_LOZENGE_CLASS, TAG_LOZENGE_CLASS,
"max-w-[12rem] truncate border", "max-w-[12rem] border",
color.fill, color.fill,
color.border, color.border,
color.text, color.text,
@@ -72,7 +80,20 @@ export function TagBadge({
)} )}
title={name} title={name}
> >
{shortenedName} <span ref={labelRef} className="min-w-0 truncate">
{shortenedName}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
title={t("remove_tag")}
aria-label={t("remove_tag")}
>
<X className="w-3 h-3" />
</button>
)}
</span> </span>
); );
} }
+32 -23
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Check, Search, X } from "lucide-react"; import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
@@ -26,12 +26,10 @@ const SEARCH_THRESHOLD = 10;
export function TagPicker({ export function TagPicker({
selectedIds, selectedIds,
onToggle, onToggle,
onClearAll,
touch = false, touch = false,
}: { }: {
selectedIds: string[]; selectedIds: string[];
onToggle: (tagId: string) => void; onToggle: (tagId: string) => void;
onClearAll?: () => void;
/** Larger hit areas for the mobile sheet. */ /** Larger hit areas for the mobile sheet. */
touch?: boolean; touch?: boolean;
}) { }) {
@@ -42,15 +40,32 @@ export function TagPicker({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
const showSearch = keywords.length >= SEARCH_THRESHOLD;
/**
* Tags on the message this client has no definition for - set from another
* client, or outliving the tag they were made with. Listing them is the only
* way to take one off, and they leave the list as they are deselected because
* nothing but the message itself records that they exist.
*/
const unknownIds = useMemo(
() =>
selectedIds
.filter((id) => !keywords.some((keyword) => keyword.id === id))
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
[selectedIds, keywords, tagName],
);
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
const matches = useMemo( const matches = useMemo(
() => () =>
trimmedQuery trimmedQuery
? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery)) ? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
tagName(id).toLowerCase().includes(trimmedQuery),
)
: [], : [],
// `tagName` is rebuilt whenever the definitions or the nesting setting change. [keywords, unknownIds, trimmedQuery, tagName],
[keywords, trimmedQuery, tagName],
); );
const tree = useMemo( const tree = useMemo(
@@ -111,28 +126,22 @@ export function TagPicker({
<div className="max-h-[min(20rem,60vh)] overflow-y-auto"> <div className="max-h-[min(20rem,60vh)] overflow-y-auto">
{trimmedQuery ? ( {trimmedQuery ? (
matches.length > 0 ? ( matches.length > 0 ? (
matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) matches.map((id) => renderRow(id, tagName(id)))
) : ( ) : (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p> <p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
) )
) : ( ) : (
renderBranch(tree) <>
{renderBranch(tree)}
{unknownIds.length > 0 && (
<>
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
{unknownIds.map((id) => renderRow(id, tagName(id)))}
</>
)}
</>
)} )}
</div> </div>
{onClearAll && selectedIds.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
type="button"
onClick={onClearAll}
className={cn(rowClass, "text-muted-foreground")}
>
<X className={cn("flex-shrink-0", touch ? "w-4 h-4" : "w-3 h-3")} />
<span>{t("remove_tag")}</span>
</button>
</>
)}
</> </>
); );
} }
+20 -9
View File
@@ -16,6 +16,7 @@ import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getQuoteBodies } from "@/lib/email-composer-utils"; import { getQuoteBodies } from "@/lib/email-composer-utils";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
interface ProEmailTabBodyProps { interface ProEmailTabBodyProps {
tabId: string; tabId: string;
@@ -57,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const moveToMailbox = useEmailStore((s) => s.moveToMailbox); const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal); const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes); const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const identities = useIdentityStore((s) => s.identities); const identities = useIdentityStore((s) => s.identities);
const multiAccountIdentities = useProMultiAccountIdentities(); const multiAccountIdentities = useProMultiAccountIdentities();
@@ -238,18 +238,29 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const handleSetTag = useCallback((emailId: string, tagId: string | null) => { const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
if (!email || email.id !== emailId) return; if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the // Toggle one tag, or clear them all. Matches the mail page's local
// mail page's local optimistic update. // optimistic update, down to reaching tags this client cannot name.
const keywords = { ...(email.keywords ?? {}) }; const keywords = { ...(email.keywords ?? {}) };
for (const kw of settingsKeywords) { if (tagId === null) {
delete keywords[`$label:${kw.id}`]; for (const key of Object.keys(keywords)) {
} if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
if (tagId) { keywords[key] = false;
keywords[`$label:${tagId}`] = true; }
}
} else {
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
.filter(key => keywords[key]);
if (activeKeys.length > 0) {
for (const key of activeKeys) {
keywords[key] = false;
}
} else {
keywords[KEYWORD_PREFIX + tagId] = true;
}
} }
setEmailKeywordsLocal(emailId, keywords); setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords }); setEmail({ ...email, keywords });
}, [email, settingsKeywords, setEmailKeywordsLocal]); }, [email, setEmailKeywordsLocal]);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => { const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return; if (!client || !email) return;
+25
View File
@@ -5,6 +5,7 @@ import {
getThreadParticipants, getThreadParticipants,
mergeThreadEmails, mergeThreadEmails,
getEmailTagId, getEmailTagId,
getEmailTagIds,
getThreadTagId, getThreadTagId,
getThreadTagIds, getThreadTagIds,
} from '../thread-utils'; } from '../thread-utils';
@@ -246,6 +247,30 @@ describe('mergeThreadEmails', () => {
}); });
}); });
describe('getEmailTagIds', () => {
it('gathers every tag set on the message', () => {
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
.toEqual(['red', 'work']);
});
it('reads the legacy prefix alongside the current one', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
});
it('reports a tag written under both prefixes once', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
});
it('ignores keywords set to false', () => {
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
});
it('is empty for an untagged message or none at all', () => {
expect(getEmailTagIds({ $seen: true })).toEqual([]);
expect(getEmailTagIds(undefined)).toEqual([]);
});
});
describe('getEmailTagId', () => { describe('getEmailTagId', () => {
it('returns label from $label: keyword', () => { it('returns label from $label: keyword', () => {
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red'); expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
+4 -3
View File
@@ -170,20 +170,21 @@ export const KEYWORD_PREFIX_LEGACY = "$color:";
/** /**
* Gets every tag id set on a message. * Gets every tag id set on a message.
* Reads both the current $label: prefix and the legacy $color: prefix. * Reads both the current $label: prefix and the legacy $color: prefix.
* A tag written under both spellings is one tag, so it is returned once.
*/ */
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] { export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return []; if (!keywords) return [];
const tags: string[] = []; const tags = new Set<string>();
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
tags.push( tags.add(
key.startsWith(KEYWORD_PREFIX) key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length) ? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length) : key.slice(KEYWORD_PREFIX_LEGACY.length)
); );
} }
} }
return tags; return [...tags];
} }
/** /**