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 { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user