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:
@@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
@@ -1870,18 +1871,22 @@ export default function Home() {
|
||||
if (tagId === null) {
|
||||
// Remove all tag keywords
|
||||
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;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${tagId}`;
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
// Both prefixes name the same tag when read, so taking one off has to
|
||||
// clear whichever spellings are actually set.
|
||||
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||
.filter(key => keywords[key]);
|
||||
if (activeKeys.length > 0) {
|
||||
activeKeys.forEach(key => {
|
||||
keywords[key] = false;
|
||||
});
|
||||
} else {
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -53,12 +53,28 @@ describe('TagPicker', () => {
|
||||
expect(onToggle).toHaveBeenCalledWith('work/clients');
|
||||
});
|
||||
|
||||
it('offers the clear-all row only while something is applied', () => {
|
||||
const { rerender } = render(<TagPicker selectedIds={[]} onToggle={() => {}} onClearAll={() => {}} />);
|
||||
expect(screen.queryByText('remove_tag')).not.toBeInTheDocument();
|
||||
it('lists a tag it has no definition for, so it can be taken off', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||
|
||||
rerender(<TagPicker selectedIds={['work']} onToggle={() => {}} onClearAll={() => {}} />);
|
||||
expect(screen.getByText('remove_tag')).toBeInTheDocument();
|
||||
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||
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', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
|
||||
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({
|
||||
email,
|
||||
position,
|
||||
@@ -155,7 +142,7 @@ export function EmailContextMenu({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentTagIds = getCurrentTagIds(email.keywords);
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// 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]">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => handleAction(() => onSetTag?.(tagId))}
|
||||
onClearAll={() => handleAction(() => onSetTag?.(null))}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
|
||||
@@ -133,6 +133,14 @@ export function EmailList({
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||
|
||||
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 [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -574,9 +582,9 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.data && (
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
email={contextMenu.data}
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
@@ -584,25 +592,25 @@ export function EmailList({
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)}
|
||||
onReply={() => onReply?.(contextMenuEmail!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||
onForward={() => onForward?.(contextMenuEmail!)}
|
||||
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
@@ -204,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
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
|
||||
const _formatRecipients = (
|
||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||
@@ -818,7 +806,7 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentTagIds = getCurrentTagIds(email?.keywords);
|
||||
const currentTagIds = getEmailTagIds(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.
|
||||
@@ -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">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }}
|
||||
onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -3205,7 +3192,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{emailKeywords.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
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">
|
||||
<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); }}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -3368,7 +3354,7 @@ export function EmailViewer({
|
||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
</button>
|
||||
{/* Tag (opens sub-view) */}
|
||||
{emailKeywords.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.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"
|
||||
@@ -3482,8 +3468,7 @@ export function EmailViewer({
|
||||
<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); }}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -3551,7 +3536,12 @@ export function EmailViewer({
|
||||
{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} />
|
||||
<TagBadge
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
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({
|
||||
tagId,
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tagId: string;
|
||||
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;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
@@ -61,10 +70,9 @@ export function TagBadge({
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[12rem] truncate border",
|
||||
"max-w-[12rem] border",
|
||||
color.fill,
|
||||
color.border,
|
||||
color.text,
|
||||
@@ -72,7 +80,20 @@ export function TagBadge({
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
@@ -26,12 +26,10 @@ const SEARCH_THRESHOLD = 10;
|
||||
export function TagPicker({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onClearAll,
|
||||
touch = false,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onToggle: (tagId: string) => void;
|
||||
onClearAll?: () => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
}) {
|
||||
@@ -42,15 +40,32 @@ export function TagPicker({
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
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(
|
||||
() =>
|
||||
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, trimmedQuery, tagName],
|
||||
[keywords, unknownIds, trimmedQuery, tagName],
|
||||
);
|
||||
|
||||
const tree = useMemo(
|
||||
@@ -111,28 +126,22 @@ export function TagPicker({
|
||||
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
|
||||
{trimmedQuery ? (
|
||||
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>
|
||||
)
|
||||
) : (
|
||||
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>
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -57,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
|
||||
@@ -238,18 +238,29 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
|
||||
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
// Toggle one tag, or clear them all. Matches the mail page's local
|
||||
// optimistic update, down to reaching tags this client cannot name.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (tagId) {
|
||||
keywords[`$label:${tagId}`] = true;
|
||||
if (tagId === null) {
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
}, [email, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailTagId,
|
||||
getEmailTagIds,
|
||||
getThreadTagId,
|
||||
getThreadTagIds,
|
||||
} 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', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||
|
||||
+4
-3
@@ -170,20 +170,21 @@ export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||
/**
|
||||
* Gets every tag id set on a message.
|
||||
* 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[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
const tags = new Set<string>();
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||
tags.push(
|
||||
tags.add(
|
||||
key.startsWith(KEYWORD_PREFIX)
|
||||
? key.slice(KEYWORD_PREFIX.length)
|
||||
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user