feat: improve visualization of tags
Previously tags where very much focused on color coding email and less about adding additional information. They were also visualized in different ways in different locations. This commit gets rid of all "Color-coding" references, aligns visualization of the tags across the whole project and tries to improve user experience of using tags in general. A search box is shown in the tagging control so the user can quickly search for a tag if they have a huge (more than 10) amount of tags.
This commit is contained in:
@@ -1833,7 +1833,7 @@ export default function Home() {
|
||||
keywords['$pinned'] = true;
|
||||
}
|
||||
|
||||
// Same unified-view routing as color tags: write to the email's own
|
||||
// Same unified-view routing as tags: write to the email's own
|
||||
// account via the login it is reachable through. (#281)
|
||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||
@@ -1857,25 +1857,25 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
const handleSetTag = async (emailId: string, tagId: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing label/color tags
|
||||
// Remove any existing tag keywords
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
if (color === null) {
|
||||
// Remove all label/color tags
|
||||
if (tagId === null) {
|
||||
// Remove all tag keywords
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${color}`;
|
||||
const jmapKey = `$label:${tagId}`;
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
@@ -1907,7 +1907,7 @@ export default function Home() {
|
||||
// Refresh tag counts
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
console.error("Failed to set tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3309,8 +3309,8 @@ export default function Home() {
|
||||
onArchive={async (email) => {
|
||||
await handleArchive(email);
|
||||
}}
|
||||
onSetColorTag={(emailId, color) => {
|
||||
handleSetColorTag(emailId, color);
|
||||
onSetTag={(emailId, color) => {
|
||||
handleSetTag(emailId, color);
|
||||
}}
|
||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||
if (client) {
|
||||
@@ -3534,7 +3534,7 @@ export default function Home() {
|
||||
}}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||
onUndoSpam={() => handleUndoSpam()}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagPicker } from '../tag-picker';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
];
|
||||
|
||||
/** Ten tags is the point at which the filter box appears. */
|
||||
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
label: i === 0 ? 'Invoices' : `Tag ${i}`,
|
||||
color: 'blue',
|
||||
}));
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names a nested tag by its own label, not the whole path', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
// The tree conveys the hierarchy, so a child needs only its own name.
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indents each level below its parent', () => {
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
const acme = screen.getByText('Acme');
|
||||
|
||||
// Two levels down: two nested indent wrappers between it and the list.
|
||||
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
|
||||
expect(indents).not.toBeNull();
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the applied tags and reports toggles by id', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('Clients').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(row);
|
||||
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();
|
||||
|
||||
rerender(<TagPicker selectedIds={['work']} onToggle={() => {}} onClearAll={() => {}} />);
|
||||
expect(screen.getByText('remove_tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the filter box until the list is long enough to need one', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
|
||||
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flattens to matches while filtering, and says so when there are none', () => {
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
|
||||
expect(within(container).getByText('Invoices')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
|
||||
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the full path, so a child is reachable by its parent name', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
|
||||
// Filtered rows are flat, so they carry the whole path.
|
||||
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists tags flat when nesting is off', () => {
|
||||
useSettingsStore.setState({ nestedTags: false });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(0);
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,67 @@ describe('ThreadListItem tag badge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem multi-message thread', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
function renderThread(emails: Email[], expanded = false) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expanded}
|
||||
expandedEmails={expanded ? emails : undefined}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('carries the tags of every message, not just the first', () => {
|
||||
// A collapsed row stands in for the whole thread, so a tag applied only to
|
||||
// a later message still has to surface.
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag shared by several messages once', () => {
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText('Red')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows each message its own tags once the thread is expanded', () => {
|
||||
renderThread(
|
||||
[
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
// Once on the header and once on the message that carries it.
|
||||
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row content', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
Archive,
|
||||
FolderInput,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
@@ -36,11 +34,9 @@ import {
|
||||
XCircle,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { TagOptionLabel } from "./tag-option-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -68,7 +64,7 @@ interface EmailContextMenuProps {
|
||||
onTogglePinned?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
@@ -103,8 +99,8 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get all active label/color tag IDs from email keywords
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): 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)) {
|
||||
@@ -137,7 +133,7 @@ export function EmailContextMenu({
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMoveToMailbox,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
@@ -154,15 +150,12 @@ export function EmailContextMenu({
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const tEmailViewer = useTranslations("email_viewer");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagNameCandidates } = useKeywordFormat();
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const currentTagIds = getCurrentTagIds(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
@@ -171,13 +164,6 @@ export function EmailContextMenu({
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
candidates: tagNameCandidates(kw.id),
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||
}));
|
||||
|
||||
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||
const moveTargetIds = new Set(
|
||||
mailboxes
|
||||
@@ -383,39 +369,14 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
<div className="max-w-[18rem]">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<TagOptionLabel candidates={option.candidates} />
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => handleAction(() => onSetTag?.(tagId))}
|
||||
onClearAll={() => handleAction(() => onSetTag?.(null))}
|
||||
/>
|
||||
</div>
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={X}
|
||||
label={t("remove_color")}
|
||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
||||
onArchive?.();
|
||||
break;
|
||||
case "tag":
|
||||
onSetColorTag?.(null);
|
||||
onSetTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
|
||||
@@ -39,7 +40,7 @@ interface EmailListProps {
|
||||
onTogglePinned?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
@@ -70,7 +71,7 @@ export function EmailList({
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
@@ -136,6 +137,8 @@ export function EmailList({
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// One tag treatment for the whole list, measured from the scroll container.
|
||||
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -332,6 +335,7 @@ export function EmailList({
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<TagDisplayContext.Provider value={tagDisplay}>
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
{/* Batch Actions Toolbar */}
|
||||
<div
|
||||
@@ -543,7 +547,7 @@ export function EmailList({
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
@@ -591,7 +595,7 @@ export function EmailList({
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
@@ -645,5 +649,6 @@ export function EmailList({
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</TagDisplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { TagOptionLabel } from "./tag-option-label";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
@@ -76,7 +78,7 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -117,7 +119,7 @@ interface EmailViewerProps {
|
||||
onArchive?: () => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||
onQuickReply?: (body: string) => Promise<void>;
|
||||
onMarkAsSpam?: () => void;
|
||||
@@ -202,7 +204,7 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
const getCurrentTagIds = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
@@ -630,7 +632,7 @@ export function EmailViewer({
|
||||
onArchive,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onDownloadAttachment,
|
||||
onQuickReply,
|
||||
onMarkAsSpam,
|
||||
@@ -669,7 +671,7 @@ export function EmailViewer({
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName, tagNameCandidates } = useKeywordFormat();
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -712,12 +714,6 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
candidates: tagNameCandidates(kw.id),
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||
}));
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
@@ -822,8 +818,13 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentColors = getCurrentColors(email?.keywords);
|
||||
const currentColor = currentColors[0] ?? null;
|
||||
const currentTagIds = getCurrentTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
// The header spans the reading pane, so it measures its own width rather than
|
||||
// inheriting the message list's answer.
|
||||
const headerTagsRef = useRef<HTMLDivElement>(null);
|
||||
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
|
||||
const currentColor = currentTagIds[0] ?? null;
|
||||
|
||||
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||
@@ -1021,7 +1022,7 @@ export function EmailViewer({
|
||||
showToolbarLabels,
|
||||
isLoading,
|
||||
moveTree.length,
|
||||
colorOptions.length,
|
||||
emailKeywords.length,
|
||||
currentColor,
|
||||
isInJunkFolder,
|
||||
isTablet,
|
||||
@@ -3000,65 +3001,19 @@ export function EmailViewer({
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColors.length > 0 && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
title={t('set_tag')}
|
||||
>
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<TagOptionLabel
|
||||
candidates={tagNameCandidates(currentColors[0])}
|
||||
className="max-w-40 text-xs font-medium text-foreground"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
)}
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||
</button>
|
||||
{tagMenuOpen && (
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<TagOptionLabel candidates={option.candidates} />
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }}
|
||||
onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3250,7 +3205,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
{emailKeywords.length > 0 && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
onMouseLeave={() => setMoreMenuSub(null)}
|
||||
@@ -3264,36 +3219,12 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<TagOptionLabel candidates={option.candidates} />
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3437,19 +3368,21 @@ export function EmailViewer({
|
||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
</button>
|
||||
{/* Tag (opens sub-view) */}
|
||||
{colorOptions.length > 0 && (
|
||||
{emailKeywords.length > 0 && (
|
||||
<button
|
||||
onClick={() => setMoreMenuSub('tag')}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Tag className="w-5 h-5" />
|
||||
<span className="flex-1">{t('tag')}</span>
|
||||
{currentColors.length > 0 && (
|
||||
{currentTagIds.length > 0 && (
|
||||
<div className="flex -space-x-1 me-1">
|
||||
{currentColors.slice(0, 3).map((c) => {
|
||||
const opt = colorOptions.find((o) => o.value === c);
|
||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||
})}
|
||||
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||
<span
|
||||
key={tagId}
|
||||
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -3545,35 +3478,13 @@ export function EmailViewer({
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||
<>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||
<TagOptionLabel candidates={option.candidates} />
|
||||
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<TagPicker
|
||||
touch
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3631,28 +3542,19 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span
|
||||
key={tagId}
|
||||
className={cn("w-2.5 h-2.5 rounded-full shrink-0", dotClass)}
|
||||
title={tagName(tagId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sortedTagIds.length > 0 && (
|
||||
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
|
||||
{sortedTagIds.map((tagId) => (
|
||||
<TagBadge key={tagId} tagId={tagId} variant={headerTagVariant} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
* How much room the surface has for a tag.
|
||||
* - `badge` names the tag; `dot` only identifies it by colour.
|
||||
*/
|
||||
export type TagBadgeVariant = "badge" | "dot";
|
||||
|
||||
/**
|
||||
* The lozenge shape, shared so anything standing next to a tag lines up with
|
||||
* it rather than approximating its padding and text size.
|
||||
*/
|
||||
export const TAG_LOZENGE_CLASS =
|
||||
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
|
||||
|
||||
/**
|
||||
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
|
||||
* the spacing between them the same as the spacing within them - a wider gap on
|
||||
* one side is what makes a neighbour look indented.
|
||||
*/
|
||||
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
|
||||
|
||||
/**
|
||||
* A tag, drawn the one way tags are drawn.
|
||||
*
|
||||
* The lozenge carries the colour in its border and text rather than pairing a
|
||||
* swatch with plain text: the name is the tag, and the colour is how you pick
|
||||
* it out of a row at a glance. That also matches every other coloured pill in
|
||||
* the app, all of which set a text colour alongside the background.
|
||||
*
|
||||
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
|
||||
* clips it, so the outermost and innermost levels survive.
|
||||
*/
|
||||
export function TagBadge({
|
||||
tagId,
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
className?: string;
|
||||
}) {
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[12rem] truncate border",
|
||||
color.fill,
|
||||
color.border,
|
||||
color.text,
|
||||
className,
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{shortenedName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
* A tag name inside one of the tag pickers, shortened to what that picker has
|
||||
* room for.
|
||||
*
|
||||
* The pickers differ in width - a narrow popover, a context submenu, a
|
||||
* full-width mobile sheet - so each row measures itself instead of sharing one
|
||||
* cap. `candidates` runs longest first (see `keywordRenderings`); the full name
|
||||
* stays reachable through the tooltip.
|
||||
*/
|
||||
export function TagOptionLabel({
|
||||
candidates,
|
||||
className,
|
||||
}: {
|
||||
candidates: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [labelRef, shortenedLabel] = useShortenedText(candidates);
|
||||
|
||||
return (
|
||||
<span ref={labelRef} className={cn("min-w-0 truncate", className)} title={candidates[0]}>
|
||||
{shortenedLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* The list of tags to apply to a message.
|
||||
*
|
||||
* Shared by all four places one appears - the toolbar popover, the overflow
|
||||
* flyout, the mobile sheet and the context menu - because they had drifted into
|
||||
* four different dot sizes, check alignments and separators, and only one of
|
||||
* them capped its height.
|
||||
*
|
||||
* Nested tags are drawn as a tree rather than repeating the parent's name on
|
||||
* every child. Filtering flattens it: with a query the hierarchy is noise, and
|
||||
* the full path is what gets matched.
|
||||
*/
|
||||
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;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const showSearch = keywords.length >= SEARCH_THRESHOLD;
|
||||
|
||||
const matches = useMemo(
|
||||
() =>
|
||||
trimmedQuery
|
||||
? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery))
|
||||
: [],
|
||||
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
|
||||
[keywords, trimmedQuery, tagName],
|
||||
);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
|
||||
[keywords, nestedTags],
|
||||
);
|
||||
|
||||
const rowClass = cn(
|
||||
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
|
||||
);
|
||||
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
|
||||
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
|
||||
|
||||
const renderRow = (id: string, label: string) => {
|
||||
const isActive = selectedIds.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(rowClass, isActive && "bg-accent font-medium")}
|
||||
title={tagName(id)}
|
||||
>
|
||||
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
|
||||
<span className="flex-1 min-w-0 truncate">{label}</span>
|
||||
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (nodes: KeywordNode[]) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
|
||||
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("tag_filter_placeholder")}
|
||||
aria-label={t("tag_filter_placeholder")}
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
|
||||
{trimmedQuery ? (
|
||||
matches.length > 0 ? (
|
||||
matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id)))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
|
||||
)
|
||||
) : (
|
||||
renderBranch(tree)
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
@@ -35,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many messages a collapsed thread stands for.
|
||||
*
|
||||
* Built from the tag lozenge so it lines up with the tags it sits next to: the
|
||||
* same shape, and the same group spacing.
|
||||
*/
|
||||
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"gap-0.5",
|
||||
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -51,7 +75,7 @@ interface ThreadListItemProps {
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
@@ -63,18 +87,18 @@ interface SingleEmailItemProps {
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
rowTint: string | null;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -90,9 +114,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName } = useKeywordFormat();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
@@ -112,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
@@ -174,16 +192,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
selected && !resolvedRowTint && "shadow-sm",
|
||||
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
@@ -260,6 +278,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -283,13 +308,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span
|
||||
key={kd.id}
|
||||
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')}
|
||||
title={tagName(kd.id)}
|
||||
/>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -328,6 +346,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
@@ -353,15 +378,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)} title={tagName(kd.id)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -384,13 +400,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -412,12 +437,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
@@ -446,7 +471,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
@@ -503,12 +528,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName } = useKeywordFormat();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
@@ -525,12 +550,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
rowTint={rowTint}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
@@ -604,16 +629,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{...threadLongPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
colorTag ? colorTag : (
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isSelected && !rowTint && "shadow-sm",
|
||||
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
@@ -713,22 +738,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
// Matches SingleEmailItem: the sender column sets where
|
||||
// every row's tags and subject begin, so the two have to
|
||||
// agree or thread rows sit 1rem further right.
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -752,12 +780,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && (
|
||||
<span
|
||||
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')}
|
||||
title={tagName(keywordDef.id)}
|
||||
/>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -796,17 +818,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
@@ -833,15 +853,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)} title={tagName(keywordDef.id)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -864,13 +875,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -892,12 +912,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
|
||||
@@ -61,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -557,22 +557,6 @@ function MailboxTreeItem({
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_ICON_COLOR: Record<string, string> = {
|
||||
red: "text-red-600/75 dark:text-red-400/75",
|
||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
||||
green: "text-green-600/75 dark:text-green-400/75",
|
||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
||||
gray: "text-gray-500",
|
||||
};
|
||||
|
||||
function ShowAllTagsRow({
|
||||
hiddenCount,
|
||||
showAll,
|
||||
@@ -617,8 +601,8 @@ function TagItem({
|
||||
colorful: boolean;
|
||||
}) {
|
||||
const t = useTranslations('notifications');
|
||||
const { tagNameCandidates } = useKeywordFormat();
|
||||
const palette = KEYWORD_PALETTE[node.color];
|
||||
const { tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const palette = tagColor(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedTags.has(node.id);
|
||||
const isSelected = selectedKeyword === node.id;
|
||||
@@ -644,12 +628,9 @@ function TagItem({
|
||||
});
|
||||
|
||||
const tagIcon = colorful ? (
|
||||
<Tag
|
||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[node.color] || "text-muted-foreground")}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||
) : (
|
||||
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
|
||||
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -236,7 +236,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
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.
|
||||
@@ -244,9 +244,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
if (tagId) {
|
||||
keywords[`$label:${tagId}`] = true;
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
@@ -333,7 +332,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onEditDraft={handleEditDraft}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
useSettingsStore,
|
||||
KEYWORD_PALETTE,
|
||||
KEYWORD_PALETTE_ROWS,
|
||||
getKeywordVisibility,
|
||||
type KeywordDefinition,
|
||||
type KeywordVisibility,
|
||||
@@ -25,11 +26,11 @@ import {
|
||||
type KeywordNode,
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
|
||||
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||
|
||||
/** Lighter, base and darker shade of each hue, one row per shade. */
|
||||
function KeywordColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -38,19 +39,23 @@ function KeywordColorPicker({
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PALETTE_KEYS.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
{KEYWORD_PALETTE_ROWS.map((row, index) => (
|
||||
<div key={index} className="flex flex-wrap gap-1.5">
|
||||
{row.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -84,10 +89,7 @@ function KeywordRow({
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const palette = KEYWORD_PALETTE[keyword.color];
|
||||
const hasChildren = hasChildKeywords(keyword.id, keywords);
|
||||
const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags));
|
||||
const [nameRef, shortenedName] = useShortenedText(nameCandidates);
|
||||
// Measured with the prefix attached, since that is what occupies the column.
|
||||
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
||||
.map((rendering) => KEYWORD_PREFIX + rendering);
|
||||
@@ -112,14 +114,9 @@ function KeywordRow({
|
||||
)}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
||||
<span
|
||||
ref={nameRef}
|
||||
className="flex-1 min-w-0 text-sm font-medium truncate"
|
||||
title={formatKeyword(keyword.id, keywords, nestedTags)}
|
||||
>
|
||||
{shortenedName}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
|
||||
</div>
|
||||
<span
|
||||
ref={keywordRef}
|
||||
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useKeywordFormat } from '../use-keyword-format';
|
||||
import { useSettingsStore, KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'archive', label: 'Archive', color: 'red-dark' },
|
||||
];
|
||||
|
||||
describe('useKeywordFormat', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
describe('tagColor', () => {
|
||||
it('resolves a tag to its palette entry, including the new shades', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue);
|
||||
expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']);
|
||||
});
|
||||
|
||||
it('falls back to grey for a keyword this client has no definition for', () => {
|
||||
// Set on the message by another client, or its tag was deleted here.
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray);
|
||||
});
|
||||
|
||||
it('falls back to grey for a colour that is not in the palette', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] });
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTagIds', () => {
|
||||
it('follows the order the user arranged in settings', () => {
|
||||
// Settings order is work, work/clients, archive - drag-reorderable, and
|
||||
// deliberately not alphabetical.
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([
|
||||
'work',
|
||||
'work/clients',
|
||||
'archive',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is stable however the keywords happen to arrive', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
const expected = ['work', 'work/clients', 'archive'];
|
||||
|
||||
expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected);
|
||||
expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected);
|
||||
});
|
||||
|
||||
it('follows a reordering of the settings list', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] });
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']);
|
||||
});
|
||||
|
||||
it('puts a tag with no local definition last, ordered by name', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([
|
||||
'work',
|
||||
'aa-unknown',
|
||||
'zz-unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the caller's array alone", () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
const input = ['archive', 'work'];
|
||||
|
||||
result.current.sortTagIds(input);
|
||||
|
||||
expect(input).toEqual(['archive', 'work']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import {
|
||||
useSettingsStore,
|
||||
KEYWORD_PALETTE,
|
||||
FALLBACK_KEYWORD_COLOR,
|
||||
type KeywordColor,
|
||||
} from "@/stores/settings-store";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
|
||||
/**
|
||||
* Names tags for the screen, bound to the user's tag settings.
|
||||
* Names and colours tags for the screen, bound to the user's tag settings.
|
||||
*
|
||||
* Resolving the definitions and the nesting setting here rather than at every
|
||||
* call site means no caller can forget the setting and render a nested name to
|
||||
* someone who never asked for nesting. Subscribing to it also keeps names in
|
||||
* step the moment it is toggled: reading it straight from the store inside the
|
||||
* formatter would leave every list showing stale names until something else
|
||||
* happened to re-render them.
|
||||
* someone who never asked for nesting. Subscribing to them also keeps tags in
|
||||
* step the moment either changes: reading the store inside the formatter would
|
||||
* leave every list stale until something else happened to re-render it.
|
||||
*/
|
||||
export function useKeywordFormat() {
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
@@ -22,8 +26,38 @@ export function useKeywordFormat() {
|
||||
() => ({
|
||||
/** The tag's display name. */
|
||||
tagName: (id: string) => formatKeyword(id, keywords, nested),
|
||||
|
||||
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
|
||||
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
|
||||
|
||||
/**
|
||||
* The tag's colour. Falls back to grey for a keyword this client has no
|
||||
* definition for - one created on another device, or whose tag was
|
||||
* deleted - so such a tag still shows rather than silently vanishing.
|
||||
*/
|
||||
tagColor: (id: string): KeywordColor => {
|
||||
const color = keywords.find((keyword) => keyword.id === id)?.color;
|
||||
return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
|
||||
},
|
||||
|
||||
/**
|
||||
* Tag ids in the order the user arranged them in settings.
|
||||
*
|
||||
* The keywords on a message arrive as an unordered JMAP map, so without
|
||||
* this the same two tags can swap places between rows. A tag with no
|
||||
* local definition has no place in that order, so it sorts last, by name.
|
||||
*/
|
||||
sortTagIds: (ids: string[]): string[] => {
|
||||
const rank = (id: string) => {
|
||||
const index = keywords.findIndex((keyword) => keyword.id === id);
|
||||
return index === -1 ? keywords.length : index;
|
||||
};
|
||||
return [...ids].sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[keywords, nested],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import type { TagBadgeVariant } from "@/components/email/tag-badge";
|
||||
|
||||
/**
|
||||
* Below this, a named tag beside the subject would leave the subject nothing to
|
||||
* occupy, so tags move up to the sender line instead. The split list runs
|
||||
* 240-600px wide and defaults to 384, so it reads that way until widened, while
|
||||
* the full-width focus and bottom-pane layouts keep tags with the subject.
|
||||
*/
|
||||
const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
|
||||
|
||||
/**
|
||||
* Below this there is no room to name a tag anywhere on the row, and colour
|
||||
* alone has to carry it. Well under the split list's default, because the
|
||||
* sender line still has room for a name long after the subject line does not.
|
||||
*/
|
||||
const TAG_NAME_MIN_WIDTH = 320;
|
||||
|
||||
export interface TagDisplay {
|
||||
/** Whether a tag is named or shown as colour alone. */
|
||||
variant: TagBadgeVariant;
|
||||
/** Which line of a multi-line row the tags belong on. */
|
||||
placement: "subject" | "sender";
|
||||
}
|
||||
|
||||
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
|
||||
|
||||
/**
|
||||
* How message rows should draw their tags.
|
||||
*
|
||||
* One value for the whole list, never per row: rows are all the same width, so
|
||||
* measuring each would burn a `ResizeObserver` per virtualised row and, worse,
|
||||
* let neighbours disagree - one naming its tags while the next showed dots.
|
||||
*/
|
||||
export const TagDisplayContext = createContext<TagDisplay>(NAMED_BESIDE_SUBJECT);
|
||||
|
||||
export function useTagDisplay(): TagDisplay {
|
||||
return useContext(TagDisplayContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches a container and reports what its rows have room for. Falls back to
|
||||
* naming tags beside the subject where measurement is unavailable - server
|
||||
* rendering, and jsdom under test - since that is the most informative form.
|
||||
*/
|
||||
export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDisplay {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const measured = entries[0]?.contentRect.width;
|
||||
if (measured !== undefined) setWidth(measured);
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (width === null) return NAMED_BESIDE_SUBJECT;
|
||||
return {
|
||||
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
|
||||
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
|
||||
};
|
||||
}, [width]);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
sortThreadGroups,
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailColorTag,
|
||||
getThreadColorTag,
|
||||
getEmailTagId,
|
||||
getThreadTagId,
|
||||
getThreadTagIds,
|
||||
} from '../thread-utils';
|
||||
import type { Email, ThreadGroup } from '../jmap/types';
|
||||
|
||||
@@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailColorTag', () => {
|
||||
describe('getEmailTagId', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
||||
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
||||
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
expect(getEmailTagId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailColorTag({})).toBeNull();
|
||||
expect(getEmailTagId({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
describe('getThreadTagId', () => {
|
||||
it('returns first color found across thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('blue');
|
||||
expect(getThreadTagId(emails)).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns null when no emails have color tags', () => {
|
||||
@@ -293,7 +294,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
expect(getThreadTagId(emails)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns first tag from earliest tagged email', () => {
|
||||
@@ -301,7 +302,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('red');
|
||||
expect(getThreadTagId(emails)).toBe('red');
|
||||
});
|
||||
|
||||
it('returns legacy tag from thread emails', () => {
|
||||
@@ -309,10 +310,41 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('green');
|
||||
expect(getThreadTagId(emails)).toBe('green');
|
||||
});
|
||||
|
||||
it('returns null for empty email array', () => {
|
||||
expect(getThreadColorTag([])).toBeNull();
|
||||
expect(getThreadTagId([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagIds', () => {
|
||||
it('gathers the tags of every message in the thread', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
|
||||
});
|
||||
|
||||
it('reports a tag shared by several messages once', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails)).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
|
||||
});
|
||||
|
||||
it('is empty for an untagged or empty thread', () => {
|
||||
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
|
||||
expect(getThreadTagIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+26
-9
@@ -168,10 +168,10 @@ export const KEYWORD_PREFIX = "$label:";
|
||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||
|
||||
/**
|
||||
* Gets all active label/color tag IDs from email keywords.
|
||||
* Gets every tag id set on a message.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
*/
|
||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
@@ -187,22 +187,39 @@ export function getEmailColorTags(keywords: Record<string, boolean> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label/color tag from email keywords (if any).
|
||||
* Gets the first tag id set on a message, if any.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* @deprecated Use getEmailColorTags for multi-tag support.
|
||||
* @deprecated Use getEmailTagIds for multi-tag support.
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailColorTags(keywords);
|
||||
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailTagIds(keywords);
|
||||
return tags.length > 0 ? tags[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
* The first tag id found anywhere in a thread, if any.
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
export function getThreadTagId(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
const color = getEmailTagId(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tag anywhere in a thread, deduplicated.
|
||||
*
|
||||
* A collapsed thread row stands in for all its messages, so it has to account
|
||||
* for all their tags - showing only the first message's would hide the rest
|
||||
* with nothing to indicate they exist.
|
||||
*/
|
||||
export function getThreadTagIds(emails: Email[]): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const email of emails) {
|
||||
for (const tag of getEmailTagIds(email.keywords)) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "عرض جهة الاتصال",
|
||||
"message_details": "تفاصيل الرسالة",
|
||||
"more_reply_options": "خيارات رد إضافية",
|
||||
"set_color": "تعيين وسم",
|
||||
"set_tag": "تعيين وسم",
|
||||
"tag": "وسم",
|
||||
"more_actions": "المزيد من الإجراءات",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"move_to": "نقل إلى...",
|
||||
"remove_color": "إزالة الوسم",
|
||||
"remove_tag": "إزالة الوسم",
|
||||
"more_count": "+{count} أخرى",
|
||||
"characters_count": "{count} حرفًا",
|
||||
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "معرّف الرسالة",
|
||||
"list_info": "معلومات القائمة"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "وسم لوني",
|
||||
"red": "أحمر",
|
||||
"orange": "برتقالي",
|
||||
"yellow": "أصفر",
|
||||
"green": "أخضر",
|
||||
"blue": "أزرق",
|
||||
"purple": "بنفسجي",
|
||||
"pink": "وردي",
|
||||
"none": "بلا"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "رد (r)",
|
||||
"reply_all": "الرد على الجميع (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "حذف",
|
||||
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
||||
"not_spam": "ليس مزعجًا",
|
||||
"color_tag": "وسم",
|
||||
"remove_color": "إزالة الوسم",
|
||||
"tag": "وسم",
|
||||
"items_selected": "{count} رسالة محددة",
|
||||
"edit_draft": "تعديل المسودة",
|
||||
"cancel_scheduled_send": "إلغاء الإرسال",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Mostra el contacte",
|
||||
"message_details": "Detalls del missatge",
|
||||
"more_reply_options": "Més opcions de resposta",
|
||||
"set_color": "Estableix l'etiqueta",
|
||||
"set_tag": "Estableix l'etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Més accions",
|
||||
"previous": "Anterior",
|
||||
"next": "Següent",
|
||||
"move_to": "Mou a...",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
"remove_tag": "Elimina l'etiqueta",
|
||||
"more_count": "+{count} més",
|
||||
"characters_count": "{count} caràcters",
|
||||
"quick_reply_placeholder": "Escriviu una resposta ràpida...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "ID del missatge",
|
||||
"list_info": "Informació de la llista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de color",
|
||||
"red": "Vermell",
|
||||
"orange": "Taronja",
|
||||
"yellow": "Groc",
|
||||
"green": "Verd",
|
||||
"blue": "Blau",
|
||||
"purple": "Lila",
|
||||
"pink": "Rosa",
|
||||
"none": "Cap"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Respon (r)",
|
||||
"reply_all": "Respon a tots (a)",
|
||||
@@ -2001,8 +1990,7 @@
|
||||
"delete": "Suprimeix",
|
||||
"mark_as_spam": "Denuncia com a brossa",
|
||||
"not_spam": "No és brossa",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} correus seleccionats",
|
||||
"edit_draft": "Edita l'esborrany",
|
||||
"cancel_scheduled_send": "Cancel·la l'enviament",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Zobrazit kontakt",
|
||||
"message_details": "Podrobnosti zprávy",
|
||||
"more_reply_options": "Další možnosti odpovědi",
|
||||
"set_color": "Nastavit štítek",
|
||||
"set_tag": "Nastavit štítek",
|
||||
"tag": "Štítek",
|
||||
"more_actions": "Další akce",
|
||||
"previous": "Předchozí",
|
||||
"next": "Další",
|
||||
"move_to": "Přesunout do...",
|
||||
"remove_color": "Odebrat štítek",
|
||||
"remove_tag": "Odebrat štítek",
|
||||
"more_count": "+{count} dalších",
|
||||
"characters_count": "{count} znaků",
|
||||
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "ID zprávy",
|
||||
"list_info": "Informace o konferenci"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Barevný štítek",
|
||||
"red": "Červený",
|
||||
"orange": "Oranžový",
|
||||
"yellow": "Žlutý",
|
||||
"green": "Zelený",
|
||||
"blue": "Modrý",
|
||||
"purple": "Fialový",
|
||||
"pink": "Růžový",
|
||||
"none": "Žádný"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpovědět (r)",
|
||||
"reply_all": "Odpovědět všem (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Odstranit",
|
||||
"mark_as_spam": "Nahlásit spam",
|
||||
"not_spam": "Není spam",
|
||||
"color_tag": "Štítek",
|
||||
"remove_color": "Odebrat štítek",
|
||||
"tag": "Štítek",
|
||||
"items_selected": "{count} vybraných zpráv",
|
||||
"edit_draft": "Upravit koncept",
|
||||
"cancel_scheduled_send": "Zrušit odeslání",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Vis kontakt",
|
||||
"message_details": "Beskeddetaljer",
|
||||
"more_reply_options": "Flere svar-muligheder",
|
||||
"set_color": "Sæt tag",
|
||||
"set_tag": "Sæt tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "Flere handlinger",
|
||||
"previous": "Forrige",
|
||||
"next": "Næste",
|
||||
"move_to": "Flyt til...",
|
||||
"remove_color": "Fjern tag",
|
||||
"remove_tag": "Fjern tag",
|
||||
"more_count": "+{count} mere",
|
||||
"characters_count": "{count} tegn",
|
||||
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "Besked-ID",
|
||||
"list_info": "Listeinformation"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farvetag",
|
||||
"red": "Rød",
|
||||
"orange": "Orange",
|
||||
"yellow": "Gul",
|
||||
"green": "Grøn",
|
||||
"blue": "Blå",
|
||||
"purple": "Lilla",
|
||||
"pink": "Pink",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Svar (r)",
|
||||
"reply_all": "Svar alle (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Slet",
|
||||
"mark_as_spam": "Rapportér spam",
|
||||
"not_spam": "Ikke spam",
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Fjern tag",
|
||||
"tag": "Tag",
|
||||
"items_selected": "{count} e-mails valgt",
|
||||
"edit_draft": "Redigér kladde",
|
||||
"cancel_scheduled_send": "Annuller afsendelse",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "Kontakt anzeigen",
|
||||
"message_details": "Nachrichtendetails",
|
||||
"more_reply_options": "Weitere Antwortoptionen",
|
||||
"set_color": "Label setzen",
|
||||
"set_tag": "Label setzen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"move_to": "Verschieben nach...",
|
||||
"remove_color": "Label entfernen",
|
||||
"remove_tag": "Label entfernen",
|
||||
"more_count": "+{count} weitere",
|
||||
"characters_count": "{count} Zeichen",
|
||||
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "Nachrichten-ID",
|
||||
"list_info": "Listeninformationen"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farb-Tag",
|
||||
"red": "Rot",
|
||||
"orange": "Orange",
|
||||
"yellow": "Gelb",
|
||||
"green": "Grün",
|
||||
"blue": "Blau",
|
||||
"purple": "Violett",
|
||||
"pink": "Rosa",
|
||||
"none": "Keine"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Antworten",
|
||||
"reply_all": "Allen antworten (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Löschen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Kein Spam",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label entfernen",
|
||||
"tag": "Label",
|
||||
"items_selected": "{count} E-Mails ausgewählt",
|
||||
"edit_draft": "Entwurf bearbeiten",
|
||||
"cancel_scheduled_send": "Senden abbrechen",
|
||||
|
||||
+6
-16
@@ -327,13 +327,15 @@
|
||||
"view_contact": "View contact",
|
||||
"message_details": "Message Details",
|
||||
"more_reply_options": "More reply options",
|
||||
"set_color": "Set tag",
|
||||
"set_tag": "Set tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "More actions",
|
||||
"previous": "Prev",
|
||||
"next": "Next",
|
||||
"move_to": "Move to...",
|
||||
"remove_color": "Remove tag",
|
||||
"remove_tag": "Remove tag",
|
||||
"tag_filter_placeholder": "Filter tags",
|
||||
"tag_no_matches": "No matching tags",
|
||||
"more_count": "+{count} more",
|
||||
"characters_count": "{count} characters",
|
||||
"quick_reply_placeholder": "Write a quick reply...",
|
||||
@@ -427,17 +429,6 @@
|
||||
"message_id": "Message ID",
|
||||
"list_info": "List Information"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Color Tag",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"pink": "Pink",
|
||||
"none": "None"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Reply (r)",
|
||||
"reply_all": "Reply All (a)",
|
||||
@@ -1019,7 +1010,7 @@
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Email Tags",
|
||||
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
|
||||
"description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.",
|
||||
"add_keyword": "Add Tag",
|
||||
"label_field": "Display Name",
|
||||
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||
@@ -2050,8 +2041,7 @@
|
||||
"delete": "Delete",
|
||||
"mark_as_spam": "Report spam",
|
||||
"not_spam": "Not spam",
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag",
|
||||
"tag": "Tag",
|
||||
"items_selected": "{count} emails selected",
|
||||
"edit_draft": "Edit Draft",
|
||||
"cancel_scheduled_send": "Cancel send",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "Ver contacto",
|
||||
"message_details": "Detalles del Mensaje",
|
||||
"more_reply_options": "Más opciones de respuesta",
|
||||
"set_color": "Establecer etiqueta",
|
||||
"set_tag": "Establecer etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Más acciones",
|
||||
"move_to": "Mover a...",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"remove_tag": "Eliminar etiqueta",
|
||||
"more_count": "+{count} más",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "ID del Mensaje",
|
||||
"list_info": "Información de Lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de Color",
|
||||
"red": "Rojo",
|
||||
"orange": "Naranja",
|
||||
"yellow": "Amarillo",
|
||||
"green": "Verde",
|
||||
"blue": "Azul",
|
||||
"purple": "Morado",
|
||||
"pink": "Rosa",
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a todos (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Eliminar",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "No es spam",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} correos seleccionados",
|
||||
"edit_draft": "Editar borrador",
|
||||
"cancel_scheduled_send": "Cancelar envío",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "مشاهده مخاطب",
|
||||
"message_details": "جزئیات پیام",
|
||||
"more_reply_options": "گزینههای بیشتر پاسخ",
|
||||
"set_color": "تنظیم برچسب",
|
||||
"set_tag": "تنظیم برچسب",
|
||||
"tag": "برچسب",
|
||||
"more_actions": "عملیات بیشتر",
|
||||
"previous": "قبلی",
|
||||
"next": "بعدی",
|
||||
"move_to": "انتقال به...",
|
||||
"remove_color": "حذف برچسب",
|
||||
"remove_tag": "حذف برچسب",
|
||||
"more_count": "+{count} بیشتر",
|
||||
"characters_count": "{count} کاراکتر",
|
||||
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "شناسه پیام",
|
||||
"list_info": "اطلاعات لیست"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "برچسب رنگی",
|
||||
"red": "قرمز",
|
||||
"orange": "نارنجی",
|
||||
"yellow": "زرد",
|
||||
"green": "سبز",
|
||||
"blue": "آبی",
|
||||
"purple": "بنفش",
|
||||
"pink": "صورتی",
|
||||
"none": "هیچکدام"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "پاسخ (r)",
|
||||
"reply_all": "پاسخ به همه (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "حذف",
|
||||
"mark_as_spam": "گزارش هرزنامه",
|
||||
"not_spam": "هرزنامه نیست",
|
||||
"color_tag": "برچسب",
|
||||
"remove_color": "حذف برچسب",
|
||||
"tag": "برچسب",
|
||||
"items_selected": "{count} ایمیل انتخاب شده",
|
||||
"edit_draft": "ویرایش پیشنویس",
|
||||
"cancel_scheduled_send": "لغو ارسال",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "Voir le contact",
|
||||
"message_details": "Détails du message",
|
||||
"more_reply_options": "Plus d'options de réponse",
|
||||
"set_color": "Définir l'étiquette",
|
||||
"set_tag": "Définir l'étiquette",
|
||||
"tag": "Étiquette",
|
||||
"more_actions": "Plus d'actions",
|
||||
"move_to": "Déplacer vers...",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
"remove_tag": "Retirer l'étiquette",
|
||||
"more_count": "+{count} de plus",
|
||||
"characters_count": "{count} caractères",
|
||||
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "ID du message",
|
||||
"list_info": "Information de liste"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Étiquette de couleur",
|
||||
"red": "Rouge",
|
||||
"orange": "Orange",
|
||||
"yellow": "Jaune",
|
||||
"green": "Vert",
|
||||
"blue": "Bleu",
|
||||
"purple": "Violet",
|
||||
"pink": "Rose",
|
||||
"none": "Aucune"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Supprimer",
|
||||
"mark_as_spam": "Signaler comme spam",
|
||||
"not_spam": "Pas un spam",
|
||||
"color_tag": "Étiquette",
|
||||
"remove_color": "Supprimer l'étiquette",
|
||||
"tag": "Étiquette",
|
||||
"items_selected": "{count} emails sélectionnés",
|
||||
"edit_draft": "Modifier le brouillon",
|
||||
"cancel_scheduled_send": "Annuler l’envoi",
|
||||
|
||||
+3
-15
@@ -272,13 +272,13 @@
|
||||
"view_contact": "הצג איש קשר",
|
||||
"message_details": "פרטי הודעה",
|
||||
"more_reply_options": "אפשרויות תשובה נוספות",
|
||||
"set_color": "הגדר תג",
|
||||
"set_tag": "הגדר תג",
|
||||
"tag": "תג",
|
||||
"more_actions": "עוד פעולות",
|
||||
"previous": "הקודם",
|
||||
"next": "הבא",
|
||||
"move_to": "העבר ל...",
|
||||
"remove_color": "הסר תג",
|
||||
"remove_tag": "הסר תג",
|
||||
"more_count": "+{count}נוספים",
|
||||
"characters_count": "{count} תווים",
|
||||
"quick_reply_placeholder": "תשובה מהירה",
|
||||
@@ -347,17 +347,6 @@
|
||||
"message_id": "מזהה הודעה",
|
||||
"list_info": "רשימת מידע"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "תג צבע",
|
||||
"red": "אדום",
|
||||
"orange": "כתום",
|
||||
"yellow": "צהוב",
|
||||
"green": "ירוק",
|
||||
"blue": "כחול",
|
||||
"purple": "סגול",
|
||||
"pink": "ורוד",
|
||||
"none": "אין"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "תשובה (ר)",
|
||||
"reply_all": "השב לכולם (א)",
|
||||
@@ -1999,8 +1988,7 @@
|
||||
"delete": "לִמְחוֹק",
|
||||
"mark_as_spam": "דווח על ספאם",
|
||||
"not_spam": "לא ספאם",
|
||||
"color_tag": "תווית",
|
||||
"remove_color": "הסר תווית",
|
||||
"tag": "תווית",
|
||||
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
||||
"edit_draft": "ערוך טיוטה",
|
||||
"cancel_scheduled_send": "ביטול שליחה",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Névjegy megtekintése",
|
||||
"message_details": "Üzenet részletei",
|
||||
"more_reply_options": "További válasz opciók",
|
||||
"set_color": "Címke beállítása",
|
||||
"set_tag": "Címke beállítása",
|
||||
"tag": "Címke",
|
||||
"more_actions": "További műveletek",
|
||||
"previous": "Előző",
|
||||
"next": "Következő",
|
||||
"move_to": "Áthelyezés ide...",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
"remove_tag": "Címke eltávolítása",
|
||||
"more_count": "+{count} további",
|
||||
"characters_count": "{count} karakter",
|
||||
"quick_reply_placeholder": "Gyors válasz írása...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "Üzenet azonosító",
|
||||
"list_info": "Lista információk"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Színes címke",
|
||||
"red": "Piros",
|
||||
"orange": "Narancs",
|
||||
"yellow": "Sárga",
|
||||
"green": "Zöld",
|
||||
"blue": "Kék",
|
||||
"purple": "Lila",
|
||||
"pink": "Rózsaszín",
|
||||
"none": "Nincs"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Válasz (r)",
|
||||
"reply_all": "Válasz mindenkinek (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Törlés",
|
||||
"mark_as_spam": "Spam jelentése",
|
||||
"not_spam": "Nem spam",
|
||||
"color_tag": "Címke",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
"tag": "Címke",
|
||||
"items_selected": "{count} e-mail kijelölve",
|
||||
"edit_draft": "Piszkozat szerkesztése",
|
||||
"cancel_scheduled_send": "Küldés megszakítása",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "Visualizza contatto",
|
||||
"message_details": "Dettagli del messaggio",
|
||||
"more_reply_options": "Più opzioni di risposta",
|
||||
"set_color": "Imposta etichetta",
|
||||
"set_tag": "Imposta etichetta",
|
||||
"tag": "Etichetta",
|
||||
"more_actions": "Altre azioni",
|
||||
"move_to": "Sposta in...",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"remove_tag": "Rimuovi etichetta",
|
||||
"more_count": "+{count} altri",
|
||||
"characters_count": "{count} caratteri",
|
||||
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "ID messaggio",
|
||||
"list_info": "Informazioni lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etichetta colore",
|
||||
"red": "Rosso",
|
||||
"orange": "Arancione",
|
||||
"yellow": "Giallo",
|
||||
"green": "Verde",
|
||||
"blue": "Blu",
|
||||
"purple": "Viola",
|
||||
"pink": "Rosa",
|
||||
"none": "Nessuno"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Rispondi",
|
||||
"reply_all": "Rispondi a tutti (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Elimina",
|
||||
"mark_as_spam": "Segnala come spam",
|
||||
"not_spam": "Non spam",
|
||||
"color_tag": "Etichetta",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"tag": "Etichetta",
|
||||
"items_selected": "{count} messaggi selezionati",
|
||||
"edit_draft": "Modifica bozza",
|
||||
"cancel_scheduled_send": "Annulla invio",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "連絡先を表示",
|
||||
"message_details": "メッセージの詳細",
|
||||
"more_reply_options": "その他の返信オプション",
|
||||
"set_color": "ラベルを設定",
|
||||
"set_tag": "ラベルを設定",
|
||||
"tag": "ラベル",
|
||||
"more_actions": "その他の操作",
|
||||
"move_to": "移動...",
|
||||
"remove_color": "ラベルを削除",
|
||||
"remove_tag": "ラベルを削除",
|
||||
"more_count": "他{count}件",
|
||||
"characters_count": "{count}文字",
|
||||
"quick_reply_placeholder": "クイック返信を入力...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "メッセージID",
|
||||
"list_info": "リスト情報"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "カラータグ",
|
||||
"red": "赤",
|
||||
"orange": "オレンジ",
|
||||
"yellow": "黄色",
|
||||
"green": "緑",
|
||||
"blue": "青",
|
||||
"purple": "紫",
|
||||
"pink": "ピンク",
|
||||
"none": "なし"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "返信",
|
||||
"reply_all": "全員に返信 (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "削除",
|
||||
"mark_as_spam": "迷惑メールを報告",
|
||||
"not_spam": "迷惑メールでない",
|
||||
"color_tag": "ラベル",
|
||||
"remove_color": "ラベルを削除",
|
||||
"tag": "ラベル",
|
||||
"items_selected": "{count}件のメールを選択",
|
||||
"edit_draft": "下書きを編集",
|
||||
"cancel_scheduled_send": "送信をキャンセル",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "연락처 보기",
|
||||
"message_details": "메시지 상세 정보",
|
||||
"more_reply_options": "답장 옵션 더보기",
|
||||
"set_color": "태그 설정",
|
||||
"set_tag": "태그 설정",
|
||||
"tag": "태그",
|
||||
"more_actions": "작업 더보기",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"move_to": "이동...",
|
||||
"remove_color": "태그 제거",
|
||||
"remove_tag": "태그 제거",
|
||||
"more_count": "+{count}개 더보기",
|
||||
"characters_count": "{count}자",
|
||||
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "메시지 ID",
|
||||
"list_info": "목록 정보"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "색상 태그",
|
||||
"red": "빨간색",
|
||||
"orange": "주황색",
|
||||
"yellow": "노란색",
|
||||
"green": "초록색",
|
||||
"blue": "파란색",
|
||||
"purple": "보라색",
|
||||
"pink": "분홍색",
|
||||
"none": "없음"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "답장 (r)",
|
||||
"reply_all": "전체 답장 (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "삭제",
|
||||
"mark_as_spam": "스팸 신고",
|
||||
"not_spam": "정상 메일",
|
||||
"color_tag": "태그",
|
||||
"remove_color": "태그 제거",
|
||||
"tag": "태그",
|
||||
"items_selected": "{count}개의 메일 선택됨",
|
||||
"edit_draft": "임시보관 메일 수정",
|
||||
"cancel_scheduled_send": "보내기 취소",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Skatīt kontaktu",
|
||||
"message_details": "Informācija par ziņojumu",
|
||||
"more_reply_options": "Papildu atbildēšanas iespējas",
|
||||
"set_color": "Iestatīt tagu",
|
||||
"set_tag": "Iestatīt tagu",
|
||||
"tag": "Tags",
|
||||
"more_actions": "Citas darbības",
|
||||
"previous": "Iepr.",
|
||||
"next": "Nāk.",
|
||||
"move_to": "Pārvietot uz...",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"remove_tag": "Noņemt tagu",
|
||||
"more_count": "+vairāk {count}",
|
||||
"characters_count": "{count} rakstzīmes",
|
||||
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "Ziņojuma ID",
|
||||
"list_info": "Informācija par adresātu sarakstu"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Krāsu tags",
|
||||
"red": "Sarkans",
|
||||
"orange": "Oranžs",
|
||||
"yellow": "Dzeltens",
|
||||
"green": "Zaļš",
|
||||
"blue": "Zils",
|
||||
"purple": "Violets",
|
||||
"pink": "Rozā",
|
||||
"none": "Nav"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Atbildēt (r)",
|
||||
"reply_all": "Atbildēt visiem (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Dzēst",
|
||||
"mark_as_spam": "Atzīmēt kā mēstuli",
|
||||
"not_spam": "Nav mēstule",
|
||||
"color_tag": "Tags",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"tag": "Tags",
|
||||
"items_selected": "{count} vēstules atlasītas",
|
||||
"edit_draft": "Rediģēt melnrakstu",
|
||||
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||
|
||||
+6
-16
@@ -327,11 +327,13 @@
|
||||
"view_contact": "Contact bekijken",
|
||||
"message_details": "Berichtdetails",
|
||||
"more_reply_options": "Meer antwoordopties",
|
||||
"set_color": "Label instellen",
|
||||
"set_tag": "Label instellen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Meer acties",
|
||||
"move_to": "Verplaatsen naar...",
|
||||
"remove_color": "Label verwijderen",
|
||||
"remove_tag": "Label verwijderen",
|
||||
"tag_filter_placeholder": "Labels filteren",
|
||||
"tag_no_matches": "Geen overeenkomende labels",
|
||||
"more_count": "+{count} meer",
|
||||
"characters_count": "{count} tekens",
|
||||
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
||||
@@ -400,17 +402,6 @@
|
||||
"message_id": "Bericht-ID",
|
||||
"list_info": "Lijstinformatie"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Kleurtag",
|
||||
"red": "Rood",
|
||||
"orange": "Oranje",
|
||||
"yellow": "Geel",
|
||||
"green": "Groen",
|
||||
"blue": "Blauw",
|
||||
"purple": "Paars",
|
||||
"pink": "Roze",
|
||||
"none": "Geen"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Beantwoorden",
|
||||
"reply_all": "Allen beantwoorden (a)",
|
||||
@@ -1016,7 +1007,7 @@
|
||||
},
|
||||
"keywords": {
|
||||
"title": "E-maillabels",
|
||||
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
|
||||
"description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
|
||||
"add_keyword": "Label toevoegen",
|
||||
"label_field": "Weergavenaam",
|
||||
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||
@@ -2050,8 +2041,7 @@
|
||||
"delete": "Verwijderen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Geen spam",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label verwijderen",
|
||||
"tag": "Label",
|
||||
"items_selected": "{count} e-mails geselecteerd",
|
||||
"edit_draft": "Concept bewerken",
|
||||
"cancel_scheduled_send": "Verzenden annuleren",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Pokaż kontakt",
|
||||
"message_details": "Szczegóły wiadomości",
|
||||
"more_reply_options": "Więcej opcji odpowiedzi",
|
||||
"set_color": "Ustaw etykietę",
|
||||
"set_tag": "Ustaw etykietę",
|
||||
"tag": "Etykieta",
|
||||
"more_actions": "Więcej działań",
|
||||
"previous": "Poprz.",
|
||||
"next": "Nast.",
|
||||
"move_to": "Przenieś do...",
|
||||
"remove_color": "Usuń etykietę",
|
||||
"remove_tag": "Usuń etykietę",
|
||||
"more_count": "+{count} więcej",
|
||||
"characters_count": "{count} znaków",
|
||||
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "ID wiadomości",
|
||||
"list_info": "Informacje o liście"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Kolorowa etykieta",
|
||||
"red": "Czerwony",
|
||||
"orange": "Pomarańczowy",
|
||||
"yellow": "Żółty",
|
||||
"green": "Zielony",
|
||||
"blue": "Niebieski",
|
||||
"purple": "Fioletowy",
|
||||
"pink": "Różowy",
|
||||
"none": "Brak"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpowiedz (r)",
|
||||
"reply_all": "Odpowiedz wszystkim (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Usuń",
|
||||
"mark_as_spam": "Zgłoś spam",
|
||||
"not_spam": "To nie spam",
|
||||
"color_tag": "Etykieta",
|
||||
"remove_color": "Usuń etykietę",
|
||||
"tag": "Etykieta",
|
||||
"items_selected": "{count} zaznaczonych wiadomości",
|
||||
"edit_draft": "Edytuj szkic",
|
||||
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||
|
||||
+3
-15
@@ -325,11 +325,11 @@
|
||||
"view_contact": "Ver contato",
|
||||
"message_details": "Detalhes da Mensagem",
|
||||
"more_reply_options": "Mais opções de resposta",
|
||||
"set_color": "Definir etiqueta",
|
||||
"set_tag": "Definir etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Mais ações",
|
||||
"move_to": "Mover para...",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"remove_tag": "Remover etiqueta",
|
||||
"more_count": "+{count} mais",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
||||
@@ -398,17 +398,6 @@
|
||||
"message_id": "ID da Mensagem",
|
||||
"list_info": "Informações da Lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de Cor",
|
||||
"red": "Vermelho",
|
||||
"orange": "Laranja",
|
||||
"yellow": "Amarelo",
|
||||
"green": "Verde",
|
||||
"blue": "Azul",
|
||||
"purple": "Roxo",
|
||||
"pink": "Rosa",
|
||||
"none": "Nenhuma"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a todos (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Excluir",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "Não é spam",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} e-mails selecionados",
|
||||
"edit_draft": "Editar rascunho",
|
||||
"cancel_scheduled_send": "Cancelar envio",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Vizualizare contact",
|
||||
"message_details": "Detalii mesaj",
|
||||
"more_reply_options": "Mai multe opțiuni de răspuns",
|
||||
"set_color": "Setați eticheta",
|
||||
"set_tag": "Setați eticheta",
|
||||
"tag": "Etichetă",
|
||||
"more_actions": "Alte acțiuni",
|
||||
"previous": "Anterior",
|
||||
"next": "Următorul",
|
||||
"move_to": "Mergi la...",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
"remove_tag": "Eliminați eticheta",
|
||||
"more_count": "+{count} mai multe",
|
||||
"characters_count": "{count} caractere",
|
||||
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "IDul mesajelor",
|
||||
"list_info": "Informații despre listă"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etichetă de culoare",
|
||||
"red": "Roșu",
|
||||
"orange": "Portocaliu",
|
||||
"yellow": "Galben",
|
||||
"green": "Verde",
|
||||
"blue": "Albastru",
|
||||
"purple": "Violet",
|
||||
"pink": "Roz",
|
||||
"none": "Niciunul"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Răspunde (r)",
|
||||
"reply_all": "Răspunde tuturor (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Șterge",
|
||||
"mark_as_spam": "Raportează spamul",
|
||||
"not_spam": "Nu este spam",
|
||||
"color_tag": "Etichetă",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
"tag": "Etichetă",
|
||||
"items_selected": "{count} e-mailuri selectate",
|
||||
"edit_draft": "Editează schița",
|
||||
"cancel_scheduled_send": "Anulează trimiterea",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Просмотреть контакт",
|
||||
"message_details": "Детали сообщения",
|
||||
"more_reply_options": "Дополнительные параметры ответа",
|
||||
"set_color": "Установить тег",
|
||||
"set_tag": "Установить тег",
|
||||
"tag": "Тег",
|
||||
"more_actions": "Другие действия",
|
||||
"previous": "Пред.",
|
||||
"next": "След.",
|
||||
"move_to": "Переместить в...",
|
||||
"remove_color": "Удалить тег",
|
||||
"remove_tag": "Удалить тег",
|
||||
"more_count": "+{count} ещё",
|
||||
"characters_count": "{count} символов",
|
||||
"quick_reply_placeholder": "Написать быстрый ответ...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "Идентификатор сообщения",
|
||||
"list_info": "Информация о рассылке"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Цветной тег",
|
||||
"red": "Красный",
|
||||
"orange": "Оранжевый",
|
||||
"yellow": "Жёлтый",
|
||||
"green": "Зелёный",
|
||||
"blue": "Синий",
|
||||
"purple": "Фиолетовый",
|
||||
"pink": "Розовый",
|
||||
"none": "Нет"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Ответить (r)",
|
||||
"reply_all": "Ответить всем (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Удалить",
|
||||
"mark_as_spam": "Отметить как спам",
|
||||
"not_spam": "Не спам",
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Удалить тег",
|
||||
"tag": "Тег",
|
||||
"items_selected": "{count} писем выбрано",
|
||||
"edit_draft": "Редактировать черновик",
|
||||
"cancel_scheduled_send": "Отменить отправку",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Zobraziť kontakt",
|
||||
"message_details": "Podrobnosti správy",
|
||||
"more_reply_options": "Viac možností odpovede",
|
||||
"set_color": "Nastaviť štítok",
|
||||
"set_tag": "Nastaviť štítok",
|
||||
"tag": "Štítok",
|
||||
"more_actions": "Viac akcií",
|
||||
"previous": "Predchádzajúci",
|
||||
"next": "Ďalší",
|
||||
"move_to": "Presunúť do...",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
"remove_tag": "Odstrániť štítok",
|
||||
"more_count": "+{count} ďalších",
|
||||
"characters_count": "{count} znakov",
|
||||
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
||||
@@ -425,17 +425,6 @@
|
||||
"message_id": "ID správy",
|
||||
"list_info": "Informácie o zozname"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farebný štítok",
|
||||
"red": "Červený",
|
||||
"orange": "Oranžový",
|
||||
"yellow": "Žltý",
|
||||
"green": "Zelený",
|
||||
"blue": "Modrý",
|
||||
"purple": "Fialový",
|
||||
"pink": "RŪžový",
|
||||
"none": "Žiadny"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpovedať (r)",
|
||||
"reply_all": "Odpovedať všetkým (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Odstrániť",
|
||||
"mark_as_spam": "Nahlásiť spam",
|
||||
"not_spam": "Nie je spam",
|
||||
"color_tag": "Štítok",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
"tag": "Štítok",
|
||||
"items_selected": "{count} vybraných e-mailov",
|
||||
"edit_draft": "Upraviť koncept",
|
||||
"cancel_scheduled_send": "Zrušiť odoslanie",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Kişiyi görüntüle",
|
||||
"message_details": "İleti Ayrıntıları",
|
||||
"more_reply_options": "Daha fazla yanıt seçeneği",
|
||||
"set_color": "Etiket ayarla",
|
||||
"set_tag": "Etiket ayarla",
|
||||
"tag": "Etiket",
|
||||
"more_actions": "Diğer işlemler",
|
||||
"previous": "Önceki",
|
||||
"next": "Sonraki",
|
||||
"move_to": "Şuraya taşı...",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"remove_tag": "Etiketi kaldır",
|
||||
"more_count": "+{count} daha",
|
||||
"characters_count": "{count} karakter",
|
||||
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "İleti Kimliği",
|
||||
"list_info": "Liste Bilgisi"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Renk Etiketi",
|
||||
"red": "Kırmızı",
|
||||
"orange": "Turuncu",
|
||||
"yellow": "Sarı",
|
||||
"green": "Yeşil",
|
||||
"blue": "Mavi",
|
||||
"purple": "Mor",
|
||||
"pink": "Pembe",
|
||||
"none": "Yok"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Yanıtla (r)",
|
||||
"reply_all": "Tümünü Yanıtla (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Sil",
|
||||
"mark_as_spam": "Spam bildir",
|
||||
"not_spam": "Spam değil",
|
||||
"color_tag": "Etiket",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"tag": "Etiket",
|
||||
"items_selected": "{count} e-posta seçildi",
|
||||
"edit_draft": "Taslağı Düzenle",
|
||||
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "Переглянути контакт",
|
||||
"message_details": "Деталі повідомлення",
|
||||
"more_reply_options": "Більше варіантів відповіді",
|
||||
"set_color": "Встановити тег",
|
||||
"set_tag": "Встановити тег",
|
||||
"tag": "Тег",
|
||||
"more_actions": "Більше дій",
|
||||
"previous": "попередня",
|
||||
"next": "Далі",
|
||||
"move_to": "Перейти до...",
|
||||
"remove_color": "Видалити тег",
|
||||
"remove_tag": "Видалити тег",
|
||||
"more_count": "+ ще {count}",
|
||||
"characters_count": "{count} символів",
|
||||
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "ID повідомлення",
|
||||
"list_info": "Інформація про список"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Кольоровий тег",
|
||||
"red": "Червоний",
|
||||
"orange": "Помаранчевий",
|
||||
"yellow": "Жовтий",
|
||||
"green": "Зелений",
|
||||
"blue": "Синій",
|
||||
"purple": "Фіолетовий",
|
||||
"pink": "Рожевий",
|
||||
"none": "Жодного"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Відповісти (р)",
|
||||
"reply_all": "Відповісти всім (а)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "Видалити",
|
||||
"mark_as_spam": "Повідомити про спам",
|
||||
"not_spam": "Не спам",
|
||||
"color_tag": "Мітка",
|
||||
"remove_color": "Видалити мітку",
|
||||
"tag": "Мітка",
|
||||
"items_selected": "Вибрано електронних листів: {count}",
|
||||
"edit_draft": "Редагувати чернетку",
|
||||
"cancel_scheduled_send": "Скасувати надсилання",
|
||||
|
||||
+3
-15
@@ -325,13 +325,13 @@
|
||||
"view_contact": "查看联系人",
|
||||
"message_details": "邮件详情",
|
||||
"more_reply_options": "更多回复选项",
|
||||
"set_color": "设置颜色标签",
|
||||
"set_tag": "设置颜色标签",
|
||||
"tag": "标签",
|
||||
"more_actions": "更多操作",
|
||||
"previous": "上一封",
|
||||
"next": "下一封",
|
||||
"move_to": "移动到…",
|
||||
"remove_color": "删除标签",
|
||||
"remove_tag": "删除标签",
|
||||
"more_count": "+{count} 更多",
|
||||
"characters_count": "{count} 个字符",
|
||||
"quick_reply_placeholder": "快速回复...",
|
||||
@@ -400,17 +400,6 @@
|
||||
"message_id": "消息 ID",
|
||||
"list_info": "邮件列表信息"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "颜色标签",
|
||||
"red": "红色",
|
||||
"orange": "橙色",
|
||||
"yellow": "黄色",
|
||||
"green": "绿色",
|
||||
"blue": "蓝色",
|
||||
"purple": "紫色",
|
||||
"pink": "粉色",
|
||||
"none": "无"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "回复 (r)",
|
||||
"reply_all": "全部回复 (a)",
|
||||
@@ -2033,8 +2022,7 @@
|
||||
"delete": "删除",
|
||||
"mark_as_spam": "举报垃圾邮件",
|
||||
"not_spam": "不是垃圾邮件",
|
||||
"color_tag": "标签",
|
||||
"remove_color": "删除标签",
|
||||
"tag": "标签",
|
||||
"items_selected": "已选择 {count} 封邮件",
|
||||
"edit_draft": "编辑草稿",
|
||||
"cancel_scheduled_send": "取消发送",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, getKeywordVisibility } from '../settings-store';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store';
|
||||
import type { KeywordDefinition } from '../settings-store';
|
||||
|
||||
describe('settings-store keywords', () => {
|
||||
@@ -16,7 +16,7 @@ describe('settings-store keywords', () => {
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
|
||||
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
|
||||
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy();
|
||||
expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,14 +27,39 @@ describe('settings-store keywords', () => {
|
||||
});
|
||||
|
||||
describe('KEYWORD_PALETTE', () => {
|
||||
it('has 13 colors', () => {
|
||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13);
|
||||
it('has a lighter, base and darker shade of every hue', () => {
|
||||
expect(KEYWORD_PALETTE_ROWS).toHaveLength(3);
|
||||
KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13));
|
||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39);
|
||||
});
|
||||
|
||||
it('each color has dot and bg classes', () => {
|
||||
it('lays every row out in the same hue order', () => {
|
||||
const [light, base, dark] = KEYWORD_PALETTE_ROWS;
|
||||
expect(light).toEqual(base.map((key) => `${key}-light`));
|
||||
expect(dark).toEqual(base.map((key) => `${key}-dark`));
|
||||
});
|
||||
|
||||
it('keeps the bare hue name on the base row, so saved tags still resolve', () => {
|
||||
// A tag stored as `red` predates the lighter and darker rows.
|
||||
expect(KEYWORD_PALETTE_ROWS[1]).toContain('red');
|
||||
expect(KEYWORD_PALETTE.red).toBeDefined();
|
||||
});
|
||||
|
||||
it('spells every class out so Tailwind can find it', () => {
|
||||
// A composed class name would compile to nothing, so none may be built
|
||||
// at runtime and each has to carry its own utility prefix.
|
||||
Object.values(KEYWORD_PALETTE).forEach((entry) => {
|
||||
expect(entry.dot).toMatch(/^bg-/);
|
||||
expect(entry.bg).toMatch(/^bg-/);
|
||||
expect(entry.fill).toMatch(/^bg-/);
|
||||
expect(entry.border).toMatch(/^border-/);
|
||||
expect(entry.text).toMatch(/^text-.* dark:text-/);
|
||||
expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves every row key', () => {
|
||||
KEYWORD_PALETTE_ROWS.flat().forEach((key) => {
|
||||
expect(KEYWORD_PALETTE[key]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+78
-15
@@ -125,23 +125,86 @@ export interface SidebarApp {
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
|
||||
// Available color palette for keywords
|
||||
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
|
||||
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
|
||||
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
|
||||
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
||||
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
|
||||
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
|
||||
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
|
||||
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
|
||||
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
|
||||
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
||||
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
||||
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
|
||||
lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' },
|
||||
gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' },
|
||||
export interface KeywordColor {
|
||||
/** Solid swatch: the dot form and the settings swatches. */
|
||||
dot: string;
|
||||
/** The same solid colour as `dot`, for glyphs that take a text colour. */
|
||||
icon: string;
|
||||
/** Lozenge background. */
|
||||
fill: string;
|
||||
/** Lozenge border. */
|
||||
border: string;
|
||||
/** Lozenge text. */
|
||||
text: string;
|
||||
/** Full-row wash when `tintListRowsByTag` is on. */
|
||||
rowTint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag colours, written out literally.
|
||||
*
|
||||
* Tailwind v4 scans this file, but only for classes that appear verbatim -
|
||||
* a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can
|
||||
* take therefore has to be spelled out, which is why this map is long.
|
||||
*
|
||||
* Three shades per hue: the middle one keeps the bare hue name, so a tag
|
||||
* saved before the lighter and darker rows existed still resolves.
|
||||
*/
|
||||
export const KEYWORD_PALETTE: Record<string, KeywordColor> = {
|
||||
// light
|
||||
'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' },
|
||||
'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' },
|
||||
'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' },
|
||||
'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' },
|
||||
'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' },
|
||||
'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' },
|
||||
'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' },
|
||||
'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' },
|
||||
'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' },
|
||||
'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' },
|
||||
'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' },
|
||||
'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' },
|
||||
'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' },
|
||||
// base
|
||||
red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' },
|
||||
orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' },
|
||||
amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' },
|
||||
yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
||||
lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' },
|
||||
green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' },
|
||||
teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' },
|
||||
cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
||||
blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' },
|
||||
indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
||||
purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' },
|
||||
pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' },
|
||||
gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' },
|
||||
// dark
|
||||
'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' },
|
||||
'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' },
|
||||
'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' },
|
||||
'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' },
|
||||
'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' },
|
||||
'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' },
|
||||
'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' },
|
||||
'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' },
|
||||
'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' },
|
||||
'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' },
|
||||
'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' },
|
||||
'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' },
|
||||
'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' },
|
||||
} as const;
|
||||
|
||||
/** Palette laid out as the settings picker shows it: lighter, base, darker. */
|
||||
export const KEYWORD_PALETTE_ROWS: string[][] = [
|
||||
['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'],
|
||||
['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'],
|
||||
['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'],
|
||||
];
|
||||
|
||||
/** The colour a tag falls back to when its definition is gone. */
|
||||
export const FALLBACK_KEYWORD_COLOR = 'gray';
|
||||
|
||||
export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
||||
{ id: 'red', label: 'Red', color: 'red' },
|
||||
{ id: 'orange', label: 'Orange', color: 'orange' },
|
||||
|
||||
Reference in New Issue
Block a user