Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts: # app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
@@ -17,6 +17,8 @@
|
|||||||
- Multi-select for batch archive, delete, move, and tag
|
- Multi-select for batch archive, delete, move, and tag
|
||||||
- Archive directly, by year, or by month
|
- Archive directly, by year, or by month
|
||||||
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||||
|
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||||
|
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||||
- Star or unstar, with a configurable mark-as-read delay
|
- Star or unstar, with a configurable mark-as-read delay
|
||||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
|||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
|||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
<ProInterfaceRedirect />
|
<ProInterfaceRedirect />
|
||||||
|
<ImpersonationReconciler />
|
||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
|||||||
+107
-17
@@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
|
|||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
|
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
SidebarErrorFallback,
|
SidebarErrorFallback,
|
||||||
@@ -78,6 +79,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
|
|||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
@@ -785,7 +787,15 @@ export default function Home() {
|
|||||||
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmbedded || !showComposer) return;
|
if (!isEmbedded || !showComposer) return;
|
||||||
const replyTo = selectedEmail ? {
|
// pendingDraft.replyTo, when set, was built by the opener (e.g.
|
||||||
|
// handleForwardAsAttachment) with intent that must survive the hop into
|
||||||
|
// the Pro tab - mirrors the same precedence the non-embedded render path
|
||||||
|
// uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo
|
||||||
|
// : ...}`). Building fresh from selectedEmail unconditionally here would
|
||||||
|
// silently drop that intent (e.g. the synthetic message/rfc822
|
||||||
|
// attachment "Forward as attachment" stages), falling back to a normal
|
||||||
|
// quoted forward instead.
|
||||||
|
const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
@@ -801,7 +811,7 @@ export default function Home() {
|
|||||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
quoteHeaderText: composerQuoteHeader?.text,
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
} : undefined;
|
} : undefined);
|
||||||
|
|
||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||||
@@ -1540,6 +1550,77 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Forward the original message as a message/rfc822 attachment instead of
|
||||||
|
// inline-quoted text - e.g. for reporting spam to an upstream gateway
|
||||||
|
// that expects the raw original as an attachment, or preserving exact
|
||||||
|
// formatting/headers the recipient needs to see untouched. Reuses the
|
||||||
|
// same attachment-carry-forward mechanism native Forward already uses
|
||||||
|
// for a forwarded message's own attachments (see the `attachments`
|
||||||
|
// useState initializer in email-composer.tsx) - we just add one more
|
||||||
|
// synthetic entry representing the whole original message, referenced
|
||||||
|
// by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are
|
||||||
|
// account-scoped, not per-email). Skips prepareComposerQuoteHeader
|
||||||
|
// entirely, so the body starts blank instead of quoting the original.
|
||||||
|
// Takes an explicit `email` (defaulting to selectedEmail), same pattern
|
||||||
|
// handleDelete uses just below, rather than always reading selectedEmail
|
||||||
|
// from this closure - callers that just called selectEmail(email) and
|
||||||
|
// invoke this synchronously in the same tick would otherwise see the
|
||||||
|
// PRE-update value (the Zustand store updates immediately, but this
|
||||||
|
// render's selectedEmail closure doesn't until the next render),
|
||||||
|
// forwarding the previously selected message or no-op'ing on an
|
||||||
|
// unselected row. See the list context-menu wiring below.
|
||||||
|
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
|
||||||
|
if (!email) return;
|
||||||
|
// Same filename options "Export as .eml" uses (see emailFilenameOptions
|
||||||
|
// in email-viewer.tsx), so the two actions produce consistent filenames
|
||||||
|
// for the same message rather than the synthetic attachment silently
|
||||||
|
// ignoring the user's configured naming template.
|
||||||
|
const {
|
||||||
|
emailDownloadTemplate,
|
||||||
|
filenameSpaceReplacement,
|
||||||
|
filenameLowercase,
|
||||||
|
filenameStripDiacritics,
|
||||||
|
filenameCollapseSeparators,
|
||||||
|
} = useSettingsStore.getState();
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||||
|
template: emailDownloadTemplate,
|
||||||
|
spaceReplacement: filenameSpaceReplacement,
|
||||||
|
lowercase: filenameLowercase,
|
||||||
|
stripDiacritics: filenameStripDiacritics,
|
||||||
|
collapseSeparators: filenameCollapseSeparators,
|
||||||
|
});
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
const ok = await emailHooks.onBeforeForward.intercept({
|
||||||
|
originalEmailId: email.id,
|
||||||
|
originalEmail: emailToReadView(email),
|
||||||
|
mode: 'forward' as const,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
startFreshComposerSession();
|
||||||
|
setPendingDraft({
|
||||||
|
to: "",
|
||||||
|
cc: "",
|
||||||
|
bcc: "",
|
||||||
|
subject: payload.subject,
|
||||||
|
body: "",
|
||||||
|
showCc: false,
|
||||||
|
showBcc: false,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: "",
|
||||||
|
mode: "forward",
|
||||||
|
draftId: null,
|
||||||
|
replyTo: {
|
||||||
|
subject: email.subject,
|
||||||
|
attachments: [payload.attachment],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setComposerMode('forward');
|
||||||
|
setShowComposer(true);
|
||||||
|
if (isMobile) setActiveView('viewer');
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToDelete) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
@@ -1754,7 +1835,7 @@ export default function Home() {
|
|||||||
keywords['$pinned'] = true;
|
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)
|
// account via the login it is reachable through. (#281)
|
||||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||||
@@ -1778,31 +1859,35 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
const handleSetTag = async (emailId: string, tagId: string | null) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove any existing label/color tags
|
// Remove any existing tag keywords
|
||||||
const email = emails.find(e => e.id === emailId);
|
const email = emails.find(e => e.id === emailId);
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
if (color === null) {
|
if (tagId === null) {
|
||||||
// Remove all label/color tags
|
// Remove all tag keywords
|
||||||
Object.keys(keywords).forEach(key => {
|
Object.keys(keywords).forEach(key => {
|
||||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||||
keywords[key] = false;
|
keywords[key] = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const jmapKey = `$label:${color}`;
|
// Both prefixes name the same tag when read, so taking one off has to
|
||||||
if (keywords[jmapKey]) {
|
// clear whichever spellings are actually set.
|
||||||
// Toggle off if already active
|
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||||
keywords[jmapKey] = false;
|
.filter(key => keywords[key]);
|
||||||
|
if (activeKeys.length > 0) {
|
||||||
|
activeKeys.forEach(key => {
|
||||||
|
keywords[key] = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add the tag without disturbing others
|
// Add the tag without disturbing others
|
||||||
keywords[jmapKey] = true;
|
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1828,7 +1913,7 @@ export default function Home() {
|
|||||||
// Refresh tag counts
|
// Refresh tag counts
|
||||||
fetchTagCounts(client);
|
fetchTagCounts(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set color tag:", error);
|
console.error("Failed to set tag:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3226,6 +3311,10 @@ export default function Home() {
|
|||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
handleForward();
|
handleForward();
|
||||||
}}
|
}}
|
||||||
|
onForwardAsAttachment={(email) => {
|
||||||
|
selectEmail(email);
|
||||||
|
handleForwardAsAttachment(email);
|
||||||
|
}}
|
||||||
onMarkAsRead={async (email, read) => {
|
onMarkAsRead={async (email, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, email.id, read);
|
await markAsRead(client, email.id, read);
|
||||||
@@ -3245,8 +3334,8 @@ export default function Home() {
|
|||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
}}
|
}}
|
||||||
onSetColorTag={(emailId, color) => {
|
onSetTag={(emailId, color) => {
|
||||||
handleSetColorTag(emailId, color);
|
handleSetTag(emailId, color);
|
||||||
}}
|
}}
|
||||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
@@ -3456,6 +3545,7 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
// Deleting the open message returns to the list (Gmail-style),
|
// Deleting the open message returns to the list (Gmail-style),
|
||||||
// not the next email — unless the user turned the setting off.
|
// not the next email — unless the user turned the setting off.
|
||||||
@@ -3469,7 +3559,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetTag={handleSetTag}
|
||||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={() => handleUndoSpam()}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
|||||||
* Master-user impersonation via signed JWT. The token carries the target
|
* Master-user impersonation via signed JWT. The token carries the target
|
||||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||||
* master credentials from env, then mints the same session cookies the
|
* master credentials from env, then mints the same session cookies the
|
||||||
* password-login path produces. The browser is redirected to "/" and the
|
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
|
||||||
|
* ImpersonationReconciler, GH #646) and the
|
||||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||||
*
|
*
|
||||||
* Returns 404 when the feature is not configured so an unconfigured
|
* Returns 404 when the feature is not configured so an unconfigured
|
||||||
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
|
|||||||
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 303,
|
status: 303,
|
||||||
headers: { Location: '/' },
|
headers: { Location: '/?impersonated=1' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
||||||
@@ -152,7 +152,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0),
|
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0),
|
||||||
from: [{ name: 'GitHub', email: 'notifications@github.example' }],
|
from: [{ name: 'GitHub', email: 'notifications@github.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)',
|
subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)',
|
||||||
@@ -180,7 +180,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
// Newsletter with a full HTML body
|
// Newsletter with a full HTML body
|
||||||
{
|
{
|
||||||
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0),
|
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0),
|
||||||
from: [{ name: 'Sidenote', email: 'post@sidenote.example' }],
|
from: [{ name: 'Sidenote', email: 'post@sidenote.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet',
|
subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet',
|
||||||
@@ -346,7 +346,7 @@ const emails: MockEmail[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3),
|
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3),
|
||||||
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'JMAP-342 is up: vCard import',
|
subject: 'JMAP-342 is up: vCard import',
|
||||||
@@ -373,7 +373,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 8900, receivedAt: daysAgo(1),
|
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 8900, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }],
|
from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Invoice NH-2026-0284 for February',
|
subject: 'Invoice NH-2026-0284 for February',
|
||||||
@@ -522,7 +522,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6),
|
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6),
|
||||||
from: [{ name: 'Mollie Developers', email: 'developers@mollie.example' }],
|
from: [{ name: 'Mollie Developers', email: 'developers@mollie.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'API version 2023-10 stops working on 15 April',
|
subject: 'API version 2023-10 stops working on 15 April',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@@ -558,6 +559,40 @@ body {
|
|||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shake animation (for rejected input) */
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
60% {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
40%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation (for popovers) */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Slide in from right animation (for mobile views) */
|
/* Slide in from right animation (for mobile views) */
|
||||||
@keyframes slide-in-from-right {
|
@keyframes slide-in-from-right {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { render, screen, act } from '@testing-library/react';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { EmailListItem } from '../email-list-item';
|
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
|
||||||
import type { Email } from '@/lib/jmap/types';
|
|
||||||
|
|
||||||
// Mock the drag hook
|
|
||||||
vi.mock('@/hooks/use-email-drag', () => ({
|
|
||||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock identity badge
|
|
||||||
vi.mock('../email-identity-badge', () => ({
|
|
||||||
EmailIdentityBadge: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock auth store
|
|
||||||
vi.mock('@/stores/auth-store', () => ({
|
|
||||||
useAuthStore: () => ({ identities: [] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
|
||||||
id: 'email-1',
|
|
||||||
threadId: 'thread-1',
|
|
||||||
mailboxIds: { inbox: true },
|
|
||||||
keywords: { $seen: true },
|
|
||||||
size: 1000,
|
|
||||||
receivedAt: '2024-01-15T10:00:00Z',
|
|
||||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
|
||||||
subject: 'Test Subject',
|
|
||||||
hasAttachment: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('EmailListItem tag badge', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useSettingsStore.setState({
|
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
|
||||||
showPreview: false,
|
|
||||||
mailLayout: 'split',
|
|
||||||
});
|
|
||||||
useEmailStore.setState({
|
|
||||||
selectedEmailIds: new Set<string>(),
|
|
||||||
selectedMailbox: 'inbox',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show tag badge when email has no label keyword', () => {
|
|
||||||
const email = makeEmail({ keywords: { $seen: true } });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
|
||||||
// No keyword label should appear
|
|
||||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
|
||||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows tag badge with label when email has $label: keyword', () => {
|
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows tag badge for legacy $color: keyword', () => {
|
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
// Unknown tags fall back to the raw id as label with a gray dot
|
|
||||||
// (see email-list-item.tsx: keywordDefs fallback).
|
|
||||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows custom keyword label', () => {
|
|
||||||
useSettingsStore.setState({
|
|
||||||
emailKeywords: [
|
|
||||||
...DEFAULT_KEYWORDS,
|
|
||||||
{ id: 'work', label: 'Work', color: 'teal' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates badge when keyword definition changes', () => {
|
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
|
||||||
const { rerender } = render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Update label name
|
|
||||||
act(() => {
|
|
||||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
|
||||||
});
|
|
||||||
rerender(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders subject even without tag', () => {
|
|
||||||
const email = makeEmail({ subject: 'Hello World' });
|
|
||||||
render(<EmailListItem email={email} />);
|
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders inline preview text in focused mail layout', () => {
|
|
||||||
useSettingsStore.setState({
|
|
||||||
showPreview: true,
|
|
||||||
mailLayout: 'focus',
|
|
||||||
});
|
|
||||||
const email = makeEmail({ preview: 'Inline preview content' });
|
|
||||||
const { container } = render(<EmailListItem email={email} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
|
||||||
expect(container.querySelector('p')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('EmailListItem shift-range checkbox', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
|
||||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
|
||||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
|
||||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
|
||||||
// selection mode active (so the checkbox renders), anchor on e1
|
|
||||||
useEmailStore.setState({
|
|
||||||
emails: [e1, e2, e3],
|
|
||||||
selectedEmailIds: new Set(['e1']),
|
|
||||||
lastSelectedEmailId: 'e1',
|
|
||||||
selectedMailbox: 'inbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<EmailListItem email={e3} />);
|
|
||||||
// the checkbox is the first button in the row (shown in selection mode)
|
|
||||||
const checkbox = screen.getAllByRole('button')[0];
|
|
||||||
act(() => {
|
|
||||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const sel = useEmailStore.getState().selectedEmailIds;
|
|
||||||
expect(sel.has('e1')).toBe(true);
|
|
||||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
|
||||||
expect(sel.has('e3')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { TagBadge } from '../tag-badge';
|
||||||
|
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
const TAGS: KeywordDefinition[] = [
|
||||||
|
{ id: 'work', label: 'Work', color: 'blue' },
|
||||||
|
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TagBadge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the tag by its full path', () => {
|
||||||
|
render(<TagBadge tagId="work/clients" variant="badge" />);
|
||||||
|
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a tag it has no definition for by its id', () => {
|
||||||
|
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
|
||||||
|
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers removal only when asked to', () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
|
||||||
|
expect(onRemove).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the dot alone, having nowhere to put the control', () => {
|
||||||
|
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Work')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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('lists a tag it has no definition for, so it can be taken off', () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||||
|
|
||||||
|
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||||
|
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||||
|
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
|
||||||
|
|
||||||
|
// Nothing but the message says it exists, so deselecting is the last of it.
|
||||||
|
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
|
||||||
|
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts undefined tags towards the filter box, and matches them', () => {
|
||||||
|
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
|
||||||
|
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
|
||||||
|
expect(within(container).getByText('stray-3')).toBeInTheDocument();
|
||||||
|
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the filter box until the list is long enough to need one', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ThreadListItem } from '../thread-list-item';
|
||||||
|
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||||
|
import type { Email } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-email-drag', () => ({
|
||||||
|
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth-store', () => ({
|
||||||
|
useAuthStore: () => ({ identities: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||||
|
id: 'email-1',
|
||||||
|
threadId: 'thread-1',
|
||||||
|
mailboxIds: { inbox: true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 1000,
|
||||||
|
receivedAt: '2024-01-15T10:00:00Z',
|
||||||
|
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||||
|
subject: 'Test Subject',
|
||||||
|
hasAttachment: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-message thread, built through the real grouping so the fixture cannot
|
||||||
|
* drift from what the list actually feeds this component. `ThreadListItem`
|
||||||
|
* delegates to `SingleEmailItem` at that size, which is what draws every
|
||||||
|
* single-message row in the app.
|
||||||
|
*/
|
||||||
|
function renderRow(email: Email) {
|
||||||
|
const [thread] = groupEmailsByThread([email]);
|
||||||
|
return render(
|
||||||
|
<ThreadListItem
|
||||||
|
thread={thread}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggleExpand={() => {}}
|
||||||
|
onEmailSelect={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThreadListItem tag badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
|
});
|
||||||
|
useEmailStore.setState({
|
||||||
|
selectedEmailIds: new Set<string>(),
|
||||||
|
selectedMailbox: 'inbox',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show a tag badge when the email has no label keyword', () => {
|
||||||
|
renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||||
|
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a tag badge for a $label: keyword', () => {
|
||||||
|
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a tag badge for the legacy $color: keyword', () => {
|
||||||
|
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the raw id when the tag is not in settings', () => {
|
||||||
|
// A keyword created by another client, or one whose definition was deleted.
|
||||||
|
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
|
||||||
|
|
||||||
|
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a custom tag label', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
|
||||||
|
});
|
||||||
|
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a renamed tag definition', () => {
|
||||||
|
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||||
|
const { rerender } = renderRow(email);
|
||||||
|
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||||
|
});
|
||||||
|
const [thread] = groupEmailsByThread([email]);
|
||||||
|
rerender(
|
||||||
|
<ThreadListItem
|
||||||
|
thread={thread}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggleExpand={() => {}}
|
||||||
|
onEmailSelect={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
|
});
|
||||||
|
useEmailStore.setState({
|
||||||
|
selectedEmailIds: new Set<string>(),
|
||||||
|
selectedMailbox: 'inbox',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the subject without a tag', () => {
|
||||||
|
renderRow(makeEmail({ subject: 'Hello World' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders preview text inline in the focused layout', () => {
|
||||||
|
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
|
||||||
|
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||||
|
// Focused rows are one line: the preview shares the subject's element
|
||||||
|
// rather than getting a paragraph of its own.
|
||||||
|
expect(container.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThreadListItem shift-range checkbox', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||||
|
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||||
|
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||||
|
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||||
|
// Selection mode active so the checkbox renders, with the anchor on e1.
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [e1, e2, e3],
|
||||||
|
selectedEmailIds: new Set(['e1']),
|
||||||
|
lastSelectedEmailId: 'e1',
|
||||||
|
selectedMailbox: 'inbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderRow(e3);
|
||||||
|
const checkbox = screen.getAllByRole('button')[0];
|
||||||
|
act(() => {
|
||||||
|
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(selected.has('e1')).toBe(true);
|
||||||
|
expect(selected.has('e2')).toBe(true); // the row in between got filled in
|
||||||
|
expect(selected.has('e3')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThreadListItem row tint', () => {
|
||||||
|
const rowClasses = (container: HTMLElement) =>
|
||||||
|
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
|
tintListRowsByTag: true,
|
||||||
|
});
|
||||||
|
useEmailStore.setState({
|
||||||
|
selectedEmailIds: new Set(['email-1']),
|
||||||
|
selectedMailbox: 'inbox',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a checked row tinted, and says so to either theme', () => {
|
||||||
|
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||||
|
const classes = rowClasses(container);
|
||||||
|
|
||||||
|
expect(classes).toContain('bg-red-50');
|
||||||
|
expect(classes).toContain('dark:bg-red-950/30');
|
||||||
|
expect(classes).not.toContain('bg-accent/40');
|
||||||
|
expect(classes).toContain('ring-primary/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('washes a checked row that has no tint to keep', () => {
|
||||||
|
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||||
|
const classes = rowClasses(container);
|
||||||
|
|
||||||
|
expect(classes).toContain('bg-accent/40');
|
||||||
|
expect(classes).toContain('ring-primary/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the tint alone when the setting is off', () => {
|
||||||
|
useSettingsStore.setState({ tintListRowsByTag: false });
|
||||||
|
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||||
|
const classes = rowClasses(container);
|
||||||
|
|
||||||
|
expect(classes).not.toContain('bg-red-50');
|
||||||
|
expect(classes).toContain('bg-accent/40');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,8 +23,6 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Tag,
|
Tag,
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
Send,
|
Send,
|
||||||
File,
|
File,
|
||||||
@@ -34,10 +32,12 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||||
|
import { TagPicker } from "./tag-picker";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
|
|||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onTogglePinned?: () => void;
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMoveToMailbox?: (mailboxId: string) => void;
|
onMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all active label/color tag IDs from email keywords
|
|
||||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
|
||||||
if (!keywords) return [];
|
|
||||||
const tags: string[] = [];
|
|
||||||
for (const key of Object.keys(keywords)) {
|
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
|
||||||
tags.push(
|
|
||||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EmailContextMenu({
|
export function EmailContextMenu({
|
||||||
email,
|
email,
|
||||||
position,
|
position,
|
||||||
@@ -127,12 +114,13 @@ export function EmailContextMenu({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
@@ -149,13 +137,12 @@ export function EmailContextMenu({
|
|||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const tEmailViewer = useTranslations("email_viewer");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isPinned = email.keywords?.['$pinned'] === true;
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentTagIds = getEmailTagIds(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||||
@@ -164,13 +151,6 @@ export function EmailContextMenu({
|
|||||||
const isScheduled = email.isScheduled === true;
|
const isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
// Build color options from keyword definitions in settings
|
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
|
||||||
name: kw.label,
|
|
||||||
value: kw.id,
|
|
||||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Build mailbox tree for move-to submenu with proper hierarchy
|
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||||
const moveTargetIds = new Set(
|
const moveTargetIds = new Set(
|
||||||
mailboxes
|
mailboxes
|
||||||
@@ -277,6 +257,12 @@ export function EmailContextMenu({
|
|||||||
onClick={() => handleAction(onForward!)}
|
onClick={() => handleAction(onForward!)}
|
||||||
disabled={!onForward}
|
disabled={!onForward}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Paperclip}
|
||||||
|
label={tEmailViewer("forward_as_attachment")}
|
||||||
|
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||||
|
disabled={!onForwardAsAttachment || !email.blobId}
|
||||||
|
/>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -370,37 +356,13 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||||
{colorOptions.map((option) => {
|
<div className="w-56 max-w-[18rem]">
|
||||||
const isActive = currentColors.includes(option.value);
|
<TagPicker
|
||||||
return (
|
selectedIds={currentTagIds}
|
||||||
<button
|
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||||
key={option.value}
|
/>
|
||||||
role="menuitem"
|
</div>
|
||||||
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)} />
|
|
||||||
<span className="flex-1">{option.name}</span>
|
|
||||||
{isActive && (
|
|
||||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{currentColors.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
icon={X}
|
|
||||||
label={t("remove_color")}
|
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
|||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||||
// the spam quick-action flips to "not spam".
|
// the spam quick-action flips to "not spam".
|
||||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
isInJunk = false,
|
isInJunk = false,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
|||||||
onArchive?.();
|
onArchive?.();
|
||||||
break;
|
break;
|
||||||
case "tag":
|
case "tag":
|
||||||
onSetColorTag?.(null);
|
onSetTag?.(null);
|
||||||
break;
|
break;
|
||||||
case "spam":
|
case "spam":
|
||||||
if (isInJunk) onUndoSpam?.();
|
if (isInJunk) onUndoSpam?.();
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
|
||||||
import { Email } from "@/lib/jmap/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
|
||||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
|
||||||
import { EmailHoverActions } from "./email-hover-actions";
|
|
||||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
|
||||||
|
|
||||||
interface EmailListItemProps {
|
|
||||||
email: Email;
|
|
||||||
selected?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
onDoubleClick?: () => void;
|
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
|
||||||
onToggleStar?: () => void;
|
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
onArchive?: () => void;
|
|
||||||
onSetColorTag?: (color: string | null) => void;
|
|
||||||
onMarkAsSpam?: () => void;
|
|
||||||
onUndoSpam?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
|
||||||
const t = useTranslations('email_viewer');
|
|
||||||
const tBatch = useTranslations('email_list.batch_actions');
|
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
|
||||||
const density = useSettingsStore((state) => state.density);
|
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
|
||||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
|
||||||
const { identities } = useAuthStore();
|
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
|
||||||
const isStarred = email.keywords?.$flagged;
|
|
||||||
const isPinned = email.keywords?.['$pinned'] === true;
|
|
||||||
const isImportant = email.keywords?.["$important"];
|
|
||||||
const isAnswered = email.keywords?.$answered;
|
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
|
||||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
|
||||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
|
||||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
|
||||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
|
||||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
|
||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
|
||||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
|
||||||
const colorTagIds = getEmailColorTags(email.keywords);
|
|
||||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
|
||||||
// Use first tag for background coloring
|
|
||||||
const keywordDef = keywordDefs[0] ?? null;
|
|
||||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
|
||||||
|
|
||||||
// Drag and drop functionality
|
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
|
||||||
email,
|
|
||||||
sourceMailboxId: selectedMailbox,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
|
||||||
useCallback((pos) => {
|
|
||||||
onContextMenu?.(
|
|
||||||
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
|
|
||||||
email
|
|
||||||
);
|
|
||||||
}, [onContextMenu, email]),
|
|
||||||
isMobile
|
|
||||||
);
|
|
||||||
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
|
||||||
|
|
||||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
// Shift-click extends the selection from the anchor to here, like
|
|
||||||
// shift-clicking the row (the checkbox stops propagation, so the
|
|
||||||
// row's shift handler never runs — replicate it here).
|
|
||||||
selectRangeEmails(email.id);
|
|
||||||
} else {
|
|
||||||
toggleEmailSelection(email.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
|
||||||
onContextMenu?.(e, email);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...dragHandlers}
|
|
||||||
{...longPressHandlers}
|
|
||||||
className={cn(
|
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
|
||||||
// Apply color tag as background, with selected and unread states
|
|
||||||
colorTag ? colorTag : (
|
|
||||||
selected
|
|
||||||
? "bg-selection"
|
|
||||||
: "bg-background"
|
|
||||||
),
|
|
||||||
selected && !colorTag && "shadow-sm",
|
|
||||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
|
||||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
|
||||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
|
||||||
// Add visual feedback for checked state
|
|
||||||
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
|
||||||
// Drag state visual feedback
|
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
|
||||||
// Long press visual feedback
|
|
||||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleEmailSelection(email.id);
|
|
||||||
} else if (e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
selectRangeEmails(email.id);
|
|
||||||
} else {
|
|
||||||
if (selectedEmailIds.size > 0) clearSelection();
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
||||||
if (!onDoubleClick) return;
|
|
||||||
e.preventDefault();
|
|
||||||
onDoubleClick();
|
|
||||||
}}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
|
||||||
>
|
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
|
||||||
{selectedEmailIds.size > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleCheckboxClick}
|
|
||||||
className={cn(
|
|
||||||
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
|
||||||
!isFocusedMailLayout && 'mt-2',
|
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
|
||||||
"active:scale-95",
|
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
|
||||||
isChecked && "text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isChecked ? (
|
|
||||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
|
||||||
) : (
|
|
||||||
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unread indicator */}
|
|
||||||
{isUnread && (
|
|
||||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
{density !== 'extra-compact' && (
|
|
||||||
<SelectableAvatar
|
|
||||||
name={sender?.name}
|
|
||||||
email={sender?.email}
|
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
|
||||||
className="flex-shrink-0 shadow-sm"
|
|
||||||
disableImages={hideJunkAvatarImages}
|
|
||||||
checked={isChecked}
|
|
||||||
onToggle={() => toggleEmailSelection(email.id)}
|
|
||||||
selectLabel={tBatch('select')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{isFocusedMailLayout ? (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<span className={cn(
|
|
||||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
|
||||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
|
||||||
)}>
|
|
||||||
{sender?.name || sender?.email || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
|
||||||
<span className={cn(
|
|
||||||
'min-w-0 truncate',
|
|
||||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
|
||||||
)}>
|
|
||||||
{email.subject || t('no_subject')}
|
|
||||||
</span>
|
|
||||||
{inlinePreview && (
|
|
||||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
|
||||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
|
||||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
|
||||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
|
||||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
||||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
||||||
{isAnswered && isForwarded && (
|
|
||||||
<>
|
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
||||||
{keywordDefs.map((kd) => (
|
|
||||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
|
||||||
))}
|
|
||||||
<span className={cn(
|
|
||||||
'text-xs tabular-nums',
|
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
|
||||||
)}>
|
|
||||||
{formatDate(email.receivedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* First Line: Sender and Date */}
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<span className={cn(
|
|
||||||
"truncate text-sm",
|
|
||||||
isUnread
|
|
||||||
? "font-bold text-foreground"
|
|
||||||
: "font-medium text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isPinned && (
|
|
||||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
|
||||||
)}
|
|
||||||
{isStarred && (
|
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
|
||||||
)}
|
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
|
||||||
{isAnswered && !isForwarded && (
|
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isForwarded && !isAnswered && (
|
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isAnswered && isForwarded && (
|
|
||||||
<>
|
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{email.hasAttachment && (
|
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
{keywordDefs.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"
|
|
||||||
)}>
|
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
|
||||||
{kd.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className={cn(
|
|
||||||
"text-xs tabular-nums",
|
|
||||||
isUnread
|
|
||||||
? "text-foreground font-semibold"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{formatDate(email.receivedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
|
||||||
<div className={cn(
|
|
||||||
"mb-1 line-clamp-1 text-sm",
|
|
||||||
isUnread
|
|
||||||
? "font-semibold text-foreground"
|
|
||||||
: "font-normal text-foreground/90"
|
|
||||||
)}>
|
|
||||||
{email.subject || t('no_subject')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
|
||||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
|
||||||
<p className={cn(
|
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{trimmedPreview || t('no_preview_available')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hover Quick Actions */}
|
|
||||||
<EmailHoverActions
|
|
||||||
email={email}
|
|
||||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
|
||||||
onToggleStar={onToggleStar}
|
|
||||||
onMarkAsRead={onMarkAsRead}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onArchive={onArchive}
|
|
||||||
onSetColorTag={onSetColorTag}
|
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
|
||||||
onUndoSpam={onUndoSpam}
|
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
|
||||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
|||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||||
import { SearchChips } from "@/components/search/search-chips";
|
import { SearchChips } from "@/components/search/search-chips";
|
||||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||||
|
|
||||||
@@ -33,12 +34,13 @@ interface EmailListProps {
|
|||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
|
onForwardAsAttachment?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
onTogglePinned?: (email: Email) => void;
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (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;
|
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
@@ -63,12 +65,13 @@ export function EmailList({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
@@ -130,10 +133,20 @@ export function EmailList({
|
|||||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
|
/**
|
||||||
|
* The row the menu was opened on, as the list currently has it. The menu holds
|
||||||
|
* the message it was handed when it opened, but tags can be applied from
|
||||||
|
* inside it without dismissing it, so what it draws has to keep up.
|
||||||
|
*/
|
||||||
|
const contextMenuEmail = contextMenu.data
|
||||||
|
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||||
|
: null;
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
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 density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -330,6 +343,7 @@ export function EmailList({
|
|||||||
}, [density, isFocusedMailLayout, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TagDisplayContext.Provider value={tagDisplay}>
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
{/* Batch Actions Toolbar */}
|
{/* Batch Actions Toolbar */}
|
||||||
<div
|
<div
|
||||||
@@ -541,7 +555,7 @@ export function EmailList({
|
|||||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetTag={onSetTag}
|
||||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -568,9 +582,9 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
{contextMenu.data && (
|
{contextMenuEmail && (
|
||||||
<EmailContextMenu
|
<EmailContextMenu
|
||||||
email={contextMenu.data}
|
email={contextMenuEmail}
|
||||||
position={contextMenu.position}
|
position={contextMenu.position}
|
||||||
isOpen={contextMenu.isOpen}
|
isOpen={contextMenu.isOpen}
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
@@ -578,24 +592,25 @@ export function EmailList({
|
|||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
currentMailboxRole={effectiveMailboxRole}
|
currentMailboxRole={effectiveMailboxRole}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenuEmail!)}
|
||||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenuEmail!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||||
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
@@ -642,5 +657,6 @@ export function EmailList({
|
|||||||
|
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
|
</TagDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
|
import { TagBadge } from "./tag-badge";
|
||||||
|
import { TagPicker } from "./tag-picker";
|
||||||
|
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { generateEmailSource } from "@/lib/email-source";
|
import { generateEmailSource } from "@/lib/email-source";
|
||||||
@@ -19,6 +24,7 @@ import {
|
|||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
Forward,
|
Forward,
|
||||||
|
Paperclip,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
Star,
|
Star,
|
||||||
@@ -73,7 +79,7 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -109,11 +115,12 @@ interface EmailViewerProps {
|
|||||||
onReply?: (draftText?: string) => void;
|
onReply?: (draftText?: string) => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (emailId: string, read: boolean) => 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;
|
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||||
onQuickReply?: (body: string) => Promise<void>;
|
onQuickReply?: (body: string) => Promise<void>;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
@@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
|||||||
return 'Attachment';
|
return 'Attachment';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
|
||||||
if (!keywords) return [];
|
|
||||||
const tags: string[] = [];
|
|
||||||
for (const key of Object.keys(keywords)) {
|
|
||||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
|
||||||
tags.push(
|
|
||||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format recipients with contextual display
|
// Helper function to format recipients with contextual display
|
||||||
const _formatRecipients = (
|
const _formatRecipients = (
|
||||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||||
@@ -621,11 +615,12 @@ export function EmailViewer({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onDownloadAttachment,
|
onDownloadAttachment,
|
||||||
onQuickReply,
|
onQuickReply,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
@@ -664,6 +659,7 @@ export function EmailViewer({
|
|||||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -706,12 +702,6 @@ export function EmailViewer({
|
|||||||
const isScheduled = email?.isScheduled === true;
|
const isScheduled = email?.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
// Color options for email tags (from user-defined keyword settings)
|
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
|
||||||
name: kw.label,
|
|
||||||
value: kw.id,
|
|
||||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
@@ -816,8 +806,13 @@ export function EmailViewer({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||||
const currentColors = getCurrentColors(email?.keywords);
|
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||||
const currentColor = currentColors[0] ?? null;
|
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
|
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||||
@@ -1015,7 +1010,7 @@ export function EmailViewer({
|
|||||||
showToolbarLabels,
|
showToolbarLabels,
|
||||||
isLoading,
|
isLoading,
|
||||||
moveTree.length,
|
moveTree.length,
|
||||||
colorOptions.length,
|
emailKeywords.length,
|
||||||
currentColor,
|
currentColor,
|
||||||
isInJunkFolder,
|
isInJunkFolder,
|
||||||
isTablet,
|
isTablet,
|
||||||
@@ -2994,64 +2989,18 @@ export function EmailViewer({
|
|||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||||
className={cn(
|
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
title={t('set_tag')}
|
||||||
currentColors.length > 0 && "bg-muted/50"
|
|
||||||
)}
|
|
||||||
title={t('set_color')}
|
|
||||||
>
|
>
|
||||||
{currentColors.length > 0 ? (
|
<Tag className="w-4 h-4" />
|
||||||
<>
|
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||||
<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 && (
|
|
||||||
<span className="text-xs font-medium text-foreground">
|
|
||||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
|
||||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{tagMenuOpen && (
|
{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">
|
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => {
|
<TagPicker
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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)} />
|
|
||||||
<span className="truncate">{option.name}</span>
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3243,7 +3192,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag - submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
onMouseLeave={() => setMoreMenuSub(null)}
|
onMouseLeave={() => setMoreMenuSub(null)}
|
||||||
@@ -3257,36 +3206,11 @@ export function EmailViewer({
|
|||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{moreMenuSub === 'tag' && (
|
{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">
|
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => {
|
<TagPicker
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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)} />
|
|
||||||
<span className="truncate">{option.name}</span>
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3340,6 +3264,16 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
|
{/* Forward as attachment */}
|
||||||
|
{onForwardAsAttachment && email?.blobId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
{t('forward_as_attachment')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Export email */}
|
{/* Export email */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
@@ -3420,19 +3354,21 @@ export function EmailViewer({
|
|||||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||||
</button>
|
</button>
|
||||||
{/* Tag (opens sub-view) */}
|
{/* Tag (opens sub-view) */}
|
||||||
{colorOptions.length > 0 && (
|
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setMoreMenuSub('tag')}
|
onClick={() => setMoreMenuSub('tag')}
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<Tag className="w-5 h-5" />
|
<Tag className="w-5 h-5" />
|
||||||
<span className="flex-1">{t('tag')}</span>
|
<span className="flex-1">{t('tag')}</span>
|
||||||
{currentColors.length > 0 && (
|
{currentTagIds.length > 0 && (
|
||||||
<div className="flex -space-x-1 me-1">
|
<div className="flex -space-x-1 me-1">
|
||||||
{currentColors.slice(0, 3).map((c) => {
|
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||||
const opt = colorOptions.find((o) => o.value === c);
|
<span
|
||||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
key={tagId}
|
||||||
})}
|
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -3462,6 +3398,15 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
|
{onForwardAsAttachment && email?.blobId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
{t('forward_as_attachment')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
@@ -3519,35 +3464,12 @@ export function EmailViewer({
|
|||||||
};
|
};
|
||||||
return renderMobileNodes(moveTree);
|
return renderMobileNodes(moveTree);
|
||||||
})()}
|
})()}
|
||||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
{moreMenuSub === 'tag' && (
|
||||||
<>
|
<TagPicker
|
||||||
{colorOptions.map((option) => {
|
touch
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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)} />
|
|
||||||
<span className="truncate">{option.name}</span>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3605,24 +3527,24 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</button>
|
</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 flex-shrink-0", dotClass)} title={kw.label} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isImportant && (
|
{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">
|
<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')}
|
{t('important')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}
|
||||||
|
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
{/* 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">
|
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tagId: string;
|
||||||
|
variant: TagBadgeVariant;
|
||||||
|
/**
|
||||||
|
* Takes the tag off the message. Only the named form offers it - a dot is the
|
||||||
|
* size of the control it would have to hold.
|
||||||
|
*/
|
||||||
|
onRemove?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("email_viewer");
|
||||||
|
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||||
|
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||||
|
const color = tagColor(tagId);
|
||||||
|
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
|
||||||
|
className={cn(
|
||||||
|
TAG_LOZENGE_CLASS,
|
||||||
|
"max-w-[12rem] border",
|
||||||
|
color.fill,
|
||||||
|
color.border,
|
||||||
|
color.text,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<span ref={labelRef} className="min-w-0 truncate">
|
||||||
|
{shortenedName}
|
||||||
|
</span>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
title={t("remove_tag")}
|
||||||
|
aria-label={t("remove_tag")}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Check, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||||
|
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,
|
||||||
|
touch = false,
|
||||||
|
}: {
|
||||||
|
selectedIds: string[];
|
||||||
|
onToggle: (tagId: string) => 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();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags on the message this client has no definition for - set from another
|
||||||
|
* client, or outliving the tag they were made with. Listing them is the only
|
||||||
|
* way to take one off, and they leave the list as they are deselected because
|
||||||
|
* nothing but the message itself records that they exist.
|
||||||
|
*/
|
||||||
|
const unknownIds = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedIds
|
||||||
|
.filter((id) => !keywords.some((keyword) => keyword.id === id))
|
||||||
|
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
|
||||||
|
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
|
||||||
|
[selectedIds, keywords, tagName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
|
||||||
|
|
||||||
|
const matches = useMemo(
|
||||||
|
() =>
|
||||||
|
trimmedQuery
|
||||||
|
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
|
||||||
|
tagName(id).toLowerCase().includes(trimmedQuery),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[keywords, unknownIds, 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((id) => renderRow(id, tagName(id)))
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderBranch(tree)}
|
||||||
|
{unknownIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
|
||||||
|
{unknownIds.map((id) => renderRow(id, tagName(id)))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-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 {
|
interface ThreadEmailItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
|||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
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 sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
|||||||
{email.hasAttachment && (
|
{email.hasAttachment && (
|
||||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
{tagIds.map((id) => (
|
||||||
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview snippet */}
|
{/* Preview snippet */}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
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 { 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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAccountStore } from "@/stores/account-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 { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
@@ -34,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 {
|
interface ThreadListItemProps {
|
||||||
thread: ThreadGroup;
|
thread: ThreadGroup;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
|
|||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (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;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
}
|
}
|
||||||
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
|
|||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
colorTag: string | null;
|
rowTint: string | null;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
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 t = useTranslations('email_viewer');
|
||||||
const tBatch = useTranslations('email_list.batch_actions');
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||||
|
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||||
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 { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
email,
|
email,
|
||||||
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
data-unread={isUnread ? 'true' : 'false'}
|
data-unread={isUnread ? 'true' : 'false'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
resolvedColorTag ? resolvedColorTag : (
|
resolvedRowTint ? resolvedRowTint : (
|
||||||
selected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !resolvedColorTag && "shadow-sm",
|
selected && !resolvedRowTint && "shadow-sm",
|
||||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20",
|
||||||
|
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
isPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||||
|
isPressed && !resolvedRowTint && "bg-muted"
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{sender?.name || sender?.email || 'Unknown'}
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
<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(
|
<span className={cn(
|
||||||
'min-w-0 truncate',
|
'min-w-0 truncate',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{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')} />
|
|
||||||
))}
|
|
||||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}>
|
)}>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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"
|
|
||||||
)}>
|
|
||||||
<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!} />}
|
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||||
"mb-1 line-clamp-1 text-sm",
|
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||||
isUnread
|
<span className={TAG_GROUP_CLASS}>
|
||||||
? "font-semibold text-foreground"
|
{tagIds.map((id) => (
|
||||||
: "font-normal text-foreground/90"
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
)}>
|
))}
|
||||||
{email.subject || "(no subject)"}
|
</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>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{!email.isScheduled && (
|
{!email.isScheduled && (
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetTag={onSetTag}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
);
|
);
|
||||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||||
|
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
thread.emails.some(e => e.id === selectedEmailId);
|
||||||
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
colorTag={colorTag}
|
rowTint={rowTint}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(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}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{...threadLongPressHandlers}
|
{...threadLongPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||||
colorTag ? colorTag : (
|
rowTint ? rowTint : (
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
isSelected && !colorTag && "shadow-sm",
|
isSelected && !rowTint && "shadow-sm",
|
||||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||||
isExpanded && "border-b border-border/50",
|
isExpanded && "border-b border-border/50",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20",
|
||||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
isChecked && !rowTint && "bg-accent/40",
|
||||||
|
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||||
|
isThreadPressed && !rowTint && "bg-muted"
|
||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
<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'
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
)}>
|
)}>
|
||||||
{displayNames.join(', ')}
|
{displayNames.join(', ')}
|
||||||
</span>
|
</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">
|
<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(
|
<span className={cn(
|
||||||
'min-w-0 truncate',
|
'min-w-0 truncate',
|
||||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{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')} />
|
|
||||||
)}
|
|
||||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}>
|
)}>
|
||||||
{displayNames.join(", ")}
|
{displayNames.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={TAG_GROUP_CLASS}>
|
||||||
className={cn(
|
<ThreadCountPill
|
||||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
count={emailCount}
|
||||||
hasUnread
|
hasUnread={hasUnread}
|
||||||
? "bg-primary text-primary-foreground"
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
: "bg-muted text-muted-foreground"
|
/>
|
||||||
)}
|
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
>
|
))}
|
||||||
<MessageSquare className="w-3 h-3" />
|
|
||||||
{emailCount}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasPinned && (
|
{hasPinned && (
|
||||||
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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"
|
|
||||||
)}>
|
|
||||||
<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!} />}
|
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||||
"mb-1 line-clamp-1 text-sm",
|
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||||
hasUnread
|
<span className={TAG_GROUP_CLASS}>
|
||||||
? "font-semibold text-foreground"
|
{tagIds.map((id) => (
|
||||||
: "font-normal text-foreground/90"
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
)}>
|
))}
|
||||||
{latestEmail.subject || "(no subject)"}
|
</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>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{!latestEmail.isScheduled && (
|
{!latestEmail.isScheduled && (
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
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}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(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}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
|||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagName } = useKeywordFormat();
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
|||||||
>
|
>
|
||||||
<option value="">{t("label_placeholder")}</option>
|
<option value="">{t("label_placeholder")}</option>
|
||||||
{emailKeywords.map((kw) => (
|
{emailKeywords.map((kw) => (
|
||||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { evictAll } from '@/lib/account-state-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a master-user impersonation handoff (`GET /api/auth/impersonate`), the
|
||||||
|
* server swaps the slot-0 session cookie but the client's *persisted* account
|
||||||
|
* registry still lists the PREVIOUS account — so the top-left account chip keeps
|
||||||
|
* showing the old mailbox even though the message list is correctly the new one.
|
||||||
|
* Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes
|
||||||
|
* it, because that state lives in localStorage and the impersonation redirect
|
||||||
|
* never reconciles it. (Reported downstream: jabali-panel #646.)
|
||||||
|
*
|
||||||
|
* The impersonate route now redirects to `/?impersonated=1`. Here we drop the
|
||||||
|
* stale persisted account + auth state (and the server-derived caches) and
|
||||||
|
* reload to a clean URL, so the app rehydrates empty and re-derives the single
|
||||||
|
* account from the fresh session cookie — the same result as the manual
|
||||||
|
* sign-out-then-reopen, done automatically. Cookies are untouched, so the
|
||||||
|
* just-granted impersonation session survives the reload.
|
||||||
|
*/
|
||||||
|
const STALE_KEYS = [
|
||||||
|
'account-registry',
|
||||||
|
'auth-storage',
|
||||||
|
'identity-storage',
|
||||||
|
'contact-storage',
|
||||||
|
'calendar-storage',
|
||||||
|
'calendar-notification-storage',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImpersonationReconciler() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('impersonated') !== '1') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
evictAll();
|
||||||
|
} catch {
|
||||||
|
/* in-memory snapshots are best-effort */
|
||||||
|
}
|
||||||
|
for (const key of STALE_KEYS) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
/* ignore storage access errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload to a clean URL (drop the marker) so the now-empty persisted stores
|
||||||
|
// rehydrate and the app reconnects + re-derives the impersonated account
|
||||||
|
// from the session cookie. The marker is gone on the second load, so this
|
||||||
|
// runs exactly once.
|
||||||
|
params.delete('impersonated');
|
||||||
|
const query = params.toString();
|
||||||
|
window.location.replace(window.location.pathname + (query ? `?${query}` : ''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+174
-54
@@ -35,9 +35,19 @@ import {
|
|||||||
BellOff,
|
BellOff,
|
||||||
Mails,
|
Mails,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
|
MoreHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
|
import {
|
||||||
|
buildKeywordTree,
|
||||||
|
countKeywordNodes,
|
||||||
|
filterKeywordTree,
|
||||||
|
hasChildKeywords,
|
||||||
|
type KeywordNode,
|
||||||
|
} from "@/lib/keyword-nesting";
|
||||||
|
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -51,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -241,6 +251,9 @@ function SidebarRowCounts({
|
|||||||
interface SidebarRowProps {
|
interface SidebarRowProps {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Progressively shorter renderings of `label`, longest first. The widest one
|
||||||
|
* that fits the row is shown; without this the full label is used. */
|
||||||
|
labelCandidates?: string[];
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isVirtual?: boolean;
|
isVirtual?: boolean;
|
||||||
@@ -266,6 +279,7 @@ interface SidebarRowProps {
|
|||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
labelCandidates,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isVirtual = false,
|
isVirtual = false,
|
||||||
@@ -288,6 +302,7 @@ function SidebarRow({
|
|||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
|
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -353,7 +368,7 @@ function SidebarRow({
|
|||||||
</span>
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||||
<SidebarRowCounts
|
<SidebarRowCounts
|
||||||
unread={unread}
|
unread={unread}
|
||||||
total={total}
|
total={total}
|
||||||
@@ -542,78 +557,120 @@ function MailboxTreeItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_ICON_COLOR: Record<string, string> = {
|
function ShowAllTagsRow({
|
||||||
red: "text-red-600/75 dark:text-red-400/75",
|
hiddenCount,
|
||||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
showAll,
|
||||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
onToggle,
|
||||||
green: "text-green-600/75 dark:text-green-400/75",
|
isCollapsed,
|
||||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
}: {
|
||||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
hiddenCount: number;
|
||||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
showAll: boolean;
|
||||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
onToggle: () => void;
|
||||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
isCollapsed: boolean;
|
||||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
}) {
|
||||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
const t = useTranslations('sidebar');
|
||||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
|
||||||
gray: "text-gray-500",
|
return (
|
||||||
};
|
<SidebarRow
|
||||||
|
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
|
||||||
|
depth={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TagItem({
|
function TagItem({
|
||||||
kw,
|
node,
|
||||||
isSelected,
|
selectedKeyword,
|
||||||
|
expandedTags,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onTagSelect,
|
onTagSelect,
|
||||||
totalCount,
|
onToggleExpand,
|
||||||
unreadCount,
|
tagCounts,
|
||||||
colorful,
|
colorful,
|
||||||
}: {
|
}: {
|
||||||
kw: KeywordDefinition;
|
node: KeywordNode;
|
||||||
isSelected: boolean;
|
selectedKeyword: string | null;
|
||||||
|
expandedTags: Set<string>;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onTagSelect?: (keywordId: string | null) => void;
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
totalCount: number;
|
onToggleExpand: (keywordId: string) => void;
|
||||||
unreadCount: number;
|
tagCounts: Record<string, { total: number; unread: number }>;
|
||||||
colorful: boolean;
|
colorful: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('notifications');
|
const t = useTranslations('notifications');
|
||||||
const palette = KEYWORD_PALETTE[kw.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;
|
||||||
|
// Nested rows are placed by their indentation, so they show their own name.
|
||||||
|
// A root spells out its path, which matters when an intermediate tag is
|
||||||
|
// missing from this client's settings and the row would otherwise read as a
|
||||||
|
// bare leaf name.
|
||||||
|
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
|
||||||
|
const label = labelCandidates[0];
|
||||||
|
// Toasts have the room for the whole thing, and no indentation to lean on,
|
||||||
|
// so they always spell out the full path - otherwise two leaves with the
|
||||||
|
// same name in different branches (e.g. "Personal/Receipts" and
|
||||||
|
// "Work/Receipts") would read as the same tag.
|
||||||
|
const fullLabel = tagNameCandidates(node.id)[0];
|
||||||
const { isDragging: globalDragging } = useDragDropContext();
|
const { isDragging: globalDragging } = useDragDropContext();
|
||||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||||
tagId: kw.id,
|
tagId: node.id,
|
||||||
onSuccess: (count, _tagLabel) => {
|
onSuccess: (count) => {
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
toast.success(t('email_tagged'), kw.label);
|
toast.success(t('email_tagged'), fullLabel);
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('emails_tagged', { count }), kw.label);
|
toast.success(t('emails_tagged', { count }), fullLabel);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t('tag_failed'), kw.label);
|
toast.error(t('tag_failed'), fullLabel);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagIcon = colorful ? (
|
const tagIcon = colorful ? (
|
||||||
<Tag
|
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
|
|
||||||
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 (
|
return (
|
||||||
<SidebarRow
|
<>
|
||||||
icon={tagIcon}
|
<SidebarRow
|
||||||
label={kw.label}
|
icon={tagIcon}
|
||||||
depth={0}
|
label={label}
|
||||||
isSelected={isSelected}
|
labelCandidates={labelCandidates}
|
||||||
unread={unreadCount}
|
depth={node.depth}
|
||||||
total={totalCount}
|
isSelected={isSelected}
|
||||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
unread={tagCounts[node.id]?.unread ?? 0}
|
||||||
isCollapsed={isCollapsed}
|
total={tagCounts[node.id]?.total ?? 0}
|
||||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||||
isValidDropTarget={isValidDropTarget}
|
hasChildren={hasChildren}
|
||||||
/>
|
isExpanded={isExpanded}
|
||||||
|
onExpandToggle={() => onToggleExpand(node.id)}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||||
|
isValidDropTarget={isValidDropTarget}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||||
|
<TagItem
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
selectedKeyword={selectedKeyword}
|
||||||
|
expandedTags={expandedTags}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onTagSelect={onTagSelect}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
tagCounts={tagCounts}
|
||||||
|
colorful={colorful}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +794,8 @@ export function Sidebar({
|
|||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||||
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
@@ -779,6 +838,7 @@ export function Sidebar({
|
|||||||
return new Set();
|
return new Set();
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||||
// own AccountSwitcher would be a redundant second account UI in the same
|
// own AccountSwitcher would be a redundant second account UI in the same
|
||||||
@@ -842,6 +902,37 @@ export function Sidebar({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('expandedTags');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
setExpandedTags(new Set(parsed));
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to parse expanded tags:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExpandedTags(
|
||||||
|
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [emailKeywords]);
|
||||||
|
|
||||||
|
const handleToggleTagExpand = (keywordId: string) => {
|
||||||
|
setExpandedTags((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(keywordId)) {
|
||||||
|
next.delete(keywordId);
|
||||||
|
} else {
|
||||||
|
next.add(keywordId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
|
||||||
|
} catch { /* storage full or unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||||
@@ -852,6 +943,26 @@ export function Sidebar({
|
|||||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||||
|
|
||||||
|
// With nesting off every tag is its own root, so the same rows render through
|
||||||
|
// one path whether or not the ids describe a hierarchy.
|
||||||
|
const tagTree: KeywordNode[] = nestedTags
|
||||||
|
? buildKeywordTree(emailKeywords)
|
||||||
|
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
||||||
|
|
||||||
|
// Counts arrive from a separate JMAP round trip; until they land, treat every
|
||||||
|
// "show if unread" tag as visible rather than blanking the section and
|
||||||
|
// filling it back in.
|
||||||
|
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
|
||||||
|
const isTagVisible = (node: KeywordNode) => {
|
||||||
|
if (showAllTags || node.id === selectedKeyword) return true;
|
||||||
|
const visibility = getKeywordVisibility(node);
|
||||||
|
if (visibility === 'hide') return false;
|
||||||
|
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
|
||||||
|
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
|
||||||
|
|
||||||
// Multi-account mode (Pro shell): render every connected account as its
|
// Multi-account mode (Pro shell): render every connected account as its
|
||||||
// own collapsible group. The active account's tree comes from the
|
// own collapsible group. The active account's tree comes from the
|
||||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||||
@@ -1265,18 +1376,27 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{emailKeywords.map((kw) => (
|
{visibleTagTree.map((node) => (
|
||||||
<TagItem
|
<TagItem
|
||||||
key={kw.id}
|
key={node.id}
|
||||||
kw={kw}
|
node={node}
|
||||||
isSelected={selectedKeyword === kw.id}
|
selectedKeyword={selectedKeyword}
|
||||||
|
expandedTags={expandedTags}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onTagSelect={onTagSelect}
|
onTagSelect={onTagSelect}
|
||||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
onToggleExpand={handleToggleTagExpand}
|
||||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
tagCounts={tagCounts}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{(hiddenTagCount > 0 || showAllTags) && (
|
||||||
|
<ShowAllTagsRow
|
||||||
|
hiddenCount={hiddenTagCount}
|
||||||
|
showAll={showAllTags}
|
||||||
|
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st
|
|||||||
import type { Email } from "@/lib/jmap/types";
|
import type { Email } from "@/lib/jmap/types";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||||
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||||
|
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface ProEmailTabBodyProps {
|
interface ProEmailTabBodyProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -56,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||||
|
|
||||||
@@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
});
|
});
|
||||||
}, [email, openComposeTab, t]);
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
|
// Mirrors handleForward, but attaches the original as a message/rfc822
|
||||||
|
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
|
||||||
|
// This is a separate, self-contained render path from the main Mail
|
||||||
|
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
|
||||||
|
// open compose tabs directly via useProTabStore, not through
|
||||||
|
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
|
||||||
|
// wiring rather than falling out of the page.tsx fix automatically.
|
||||||
|
const handleForwardAsAttachment = useCallback(() => {
|
||||||
|
if (!email) return;
|
||||||
|
const {
|
||||||
|
emailDownloadTemplate,
|
||||||
|
filenameSpaceReplacement,
|
||||||
|
filenameLowercase,
|
||||||
|
filenameStripDiacritics,
|
||||||
|
filenameCollapseSeparators,
|
||||||
|
} = useSettingsStore.getState();
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||||
|
template: emailDownloadTemplate,
|
||||||
|
spaceReplacement: filenameSpaceReplacement,
|
||||||
|
lowercase: filenameLowercase,
|
||||||
|
stripDiacritics: filenameStripDiacritics,
|
||||||
|
collapseSeparators: filenameCollapseSeparators,
|
||||||
|
});
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
composerSessionIdRef.current += 1;
|
||||||
|
openComposeTab({
|
||||||
|
sessionId: composerSessionIdRef.current,
|
||||||
|
mode: 'forward',
|
||||||
|
replyTo: {
|
||||||
|
subject: email.subject,
|
||||||
|
attachments: [payload.attachment],
|
||||||
|
},
|
||||||
|
sourceEmailId: email.id,
|
||||||
|
// payload.subject is intentionally blank for a subject-less email (to
|
||||||
|
// match normal Forward's *composer* subject behavior - see
|
||||||
|
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
|
||||||
|
// UI label that still needs a sensible fallback, same as handleForward
|
||||||
|
// above uses - reusing payload.subject here would give the tab an
|
||||||
|
// empty title instead of e.g. "Fwd: New message".
|
||||||
|
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||||
|
});
|
||||||
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
try {
|
try {
|
||||||
@@ -191,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
}
|
}
|
||||||
}, [client, markAsRead]);
|
}, [client, markAsRead]);
|
||||||
|
|
||||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
|
||||||
if (!email || email.id !== emailId) return;
|
if (!email || email.id !== emailId) return;
|
||||||
// Drop existing color keywords, optionally add the new one. Matches the
|
// Toggle one tag, or clear them all. Matches the mail page's local
|
||||||
// mail page's local optimistic update.
|
// optimistic update, down to reaching tags this client cannot name.
|
||||||
const keywords = { ...(email.keywords ?? {}) };
|
const keywords = { ...(email.keywords ?? {}) };
|
||||||
for (const kw of settingsKeywords) {
|
if (tagId === null) {
|
||||||
delete keywords[`$label:${kw.id}`];
|
for (const key of Object.keys(keywords)) {
|
||||||
}
|
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||||
if (color) {
|
keywords[key] = false;
|
||||||
const def = settingsKeywords.find((k) => k.color === color);
|
}
|
||||||
if (def) keywords[`$label:${def.id}`] = true;
|
}
|
||||||
|
} else {
|
||||||
|
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||||
|
.filter(key => keywords[key]);
|
||||||
|
if (activeKeys.length > 0) {
|
||||||
|
for (const key of activeKeys) {
|
||||||
|
keywords[key] = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setEmailKeywordsLocal(emailId, keywords);
|
setEmailKeywordsLocal(emailId, keywords);
|
||||||
setEmail({ ...email, keywords });
|
setEmail({ ...email, keywords });
|
||||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
}, [email, setEmailKeywordsLocal]);
|
||||||
|
|
||||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
@@ -283,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onMarkAsRead={handleMarkAsRead}
|
onMarkAsRead={handleMarkAsRead}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetTag={handleSetTag}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { KeywordSettings } from '../keyword-settings';
|
import { KeywordSettings } from '../keyword-settings';
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||||
|
|
||||||
// Mock SettingsSection to just render children
|
// Mock SettingsSection to just render children, keeping the real controls
|
||||||
vi.mock('../settings-section', () => ({
|
vi.mock('../settings-section', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../settings-section')>()),
|
||||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('KeywordSettings', () => {
|
describe('KeywordSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all default keywords', () => {
|
it('renders all default keywords', () => {
|
||||||
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
|
|||||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders reset defaults button', () => {
|
|
||||||
render(<KeywordSettings />);
|
|
||||||
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows add form when add button clicked', () => {
|
it('shows add form when add button clicked', () => {
|
||||||
render(<KeywordSettings />);
|
render(<KeywordSettings />);
|
||||||
fireEvent.click(screen.getByText('add_keyword'));
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
|
|||||||
expect(kw?.label).toBe('Crimson');
|
expect(kw?.label).toBe('Crimson');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets to defaults when reset button clicked', () => {
|
|
||||||
// Modify keywords first
|
|
||||||
useSettingsStore.getState().removeKeyword('red');
|
|
||||||
useSettingsStore.getState().removeKeyword('blue');
|
|
||||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
|
|
||||||
|
|
||||||
render(<KeywordSettings />);
|
|
||||||
fireEvent.click(screen.getByText('reset_defaults'));
|
|
||||||
|
|
||||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes label to id correctly', () => {
|
it('normalizes label to id correctly', () => {
|
||||||
render(<KeywordSettings />);
|
render(<KeywordSettings />);
|
||||||
fireEvent.click(screen.getByText('add_keyword'));
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
|
|||||||
expect(added.id).toBe('my-custom-tag');
|
expect(added.id).toBe('my-custom-tag');
|
||||||
expect(added.label).toBe('My Custom Tag!');
|
expect(added.label).toBe('My Custom Tag!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers no parent picker while nesting is off', () => {
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests a new tag under the selected parent', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||||
|
nestedTags: true,
|
||||||
|
});
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
|
||||||
|
fireEvent.click(screen.getByText('add'));
|
||||||
|
|
||||||
|
const keywords = useSettingsStore.getState().emailKeywords;
|
||||||
|
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nested tags by their full path', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [
|
||||||
|
{ id: 'work', label: 'Work', color: 'blue' },
|
||||||
|
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||||
|
],
|
||||||
|
nestedTags: true,
|
||||||
|
});
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path that would exceed the keyword length limit', () => {
|
||||||
|
const deepId = 'a'.repeat(240);
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
|
||||||
|
nestedTags: true,
|
||||||
|
});
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('too_long')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('add').closest('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the name and the delete action of a tag that has nested tags', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
emailKeywords: [
|
||||||
|
{ id: 'work', label: 'Work', color: 'blue' },
|
||||||
|
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||||
|
],
|
||||||
|
nestedTags: true,
|
||||||
|
});
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByTitle('edit')[0]);
|
||||||
|
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||||
|
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults every tag to always visible in the sidebar', () => {
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
const pickers = screen.getAllByLabelText('visibility_field');
|
||||||
|
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||||
|
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the visibility chosen for a tag', () => {
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
|
||||||
|
|
||||||
|
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,35 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
import {
|
||||||
|
useSettingsStore,
|
||||||
|
KEYWORD_PALETTE,
|
||||||
|
KEYWORD_PALETTE_ROWS,
|
||||||
|
getKeywordVisibility,
|
||||||
|
type KeywordDefinition,
|
||||||
|
type KeywordVisibility,
|
||||||
|
} from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
|
||||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
|
||||||
|
import {
|
||||||
|
buildKeywordTree,
|
||||||
|
composeKeywordId,
|
||||||
|
getParentKeywordId,
|
||||||
|
hasChildKeywords,
|
||||||
|
isKeywordDescendant,
|
||||||
|
keywordLevels,
|
||||||
|
type KeywordNode,
|
||||||
|
MAX_KEYWORD_ID_LENGTH,
|
||||||
|
} from "@/lib/keyword-nesting";
|
||||||
|
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({
|
function KeywordColorPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -19,19 +39,23 @@ function KeywordColorPicker({
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="space-y-1.5">
|
||||||
{PALETTE_KEYS.map((colorKey) => (
|
{KEYWORD_PALETTE_ROWS.map((row, index) => (
|
||||||
<button
|
<div key={index} className="flex flex-wrap gap-1.5">
|
||||||
key={colorKey}
|
{row.map((colorKey) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onChange(colorKey)}
|
key={colorKey}
|
||||||
className={cn(
|
type="button"
|
||||||
"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",
|
onClick={() => onChange(colorKey)}
|
||||||
KEYWORD_PALETTE[colorKey].dot,
|
className={cn(
|
||||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
"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,
|
||||||
aria-label={colorKey}
|
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||||
/>
|
)}
|
||||||
|
aria-label={colorKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -39,8 +63,11 @@ function KeywordColorPicker({
|
|||||||
|
|
||||||
function KeywordRow({
|
function KeywordRow({
|
||||||
keyword,
|
keyword,
|
||||||
|
keywords,
|
||||||
|
nestedTags,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onVisibilityChange,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -49,8 +76,11 @@ function KeywordRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
}: {
|
}: {
|
||||||
keyword: KeywordDefinition;
|
keyword: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
|
nestedTags: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||||
onDragStart: () => void;
|
onDragStart: () => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDrop: () => void;
|
onDrop: () => void;
|
||||||
@@ -59,7 +89,16 @@ function KeywordRow({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const palette = KEYWORD_PALETTE[keyword.color];
|
const hasChildren = hasChildKeywords(keyword.id, keywords);
|
||||||
|
// 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);
|
||||||
|
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
|
||||||
|
const visibilityOptions = [
|
||||||
|
{ value: "show", label: t("visibility.show") },
|
||||||
|
{ value: "unread", label: t("visibility.unread") },
|
||||||
|
{ value: "hide", label: t("visibility.hide") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -75,9 +114,23 @@ function KeywordRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
<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")} />
|
<div className="flex min-w-0 flex-1">
|
||||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
|
||||||
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
|
</div>
|
||||||
|
<span
|
||||||
|
ref={keywordRef}
|
||||||
|
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
|
||||||
|
title={KEYWORD_PREFIX + keyword.id}
|
||||||
|
>
|
||||||
|
{shortenedKeyword}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={getKeywordVisibility(keyword)}
|
||||||
|
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
|
||||||
|
options={visibilityOptions}
|
||||||
|
ariaLabel={t("visibility_field")}
|
||||||
|
className="text-xs py-1"
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -90,8 +143,9 @@ function KeywordRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
disabled={hasChildren}
|
||||||
title={t("delete")}
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||||
|
title={hasChildren ? t("has_children_delete") : t("delete")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -102,37 +156,74 @@ function KeywordRow({
|
|||||||
|
|
||||||
function KeywordEditForm({
|
function KeywordEditForm({
|
||||||
initial,
|
initial,
|
||||||
|
keywords,
|
||||||
existingIds,
|
existingIds,
|
||||||
|
nestedTags,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: KeywordDefinition;
|
initial?: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
existingIds: string[];
|
existingIds: string[];
|
||||||
|
nestedTags: boolean;
|
||||||
onSave: (keyword: KeywordDefinition) => void;
|
onSave: (keyword: KeywordDefinition) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const [label, setLabel] = useState(initial?.label || "");
|
const [label, setLabel] = useState(initial?.label || "");
|
||||||
const [color, setColor] = useState(initial?.color || "blue");
|
const [color, setColor] = useState(initial?.color || "blue");
|
||||||
|
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
|
||||||
const isEditing = !!initial;
|
const isEditing = !!initial;
|
||||||
|
|
||||||
const normalizedId = label
|
// Renaming or re-parenting a tag rewrites the keyword on every message below
|
||||||
.trim()
|
// it, and this client only knows about the tags in its own settings - the
|
||||||
.toLowerCase()
|
// server may hold nested keywords created elsewhere. Freeze the identity of a
|
||||||
.replace(/[^a-z0-9_-]/g, "-")
|
// tag that has children and allow the color to change.
|
||||||
.replace(/-+/g, "-")
|
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
|
|
||||||
|
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
|
||||||
|
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
|
||||||
|
|
||||||
|
// Every tag is a candidate parent except the one being edited and anything
|
||||||
|
// already below it, which would detach the branch from its own root.
|
||||||
|
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
|
||||||
|
const collectParentOptions = (nodes: KeywordNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
|
||||||
|
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
|
||||||
|
collectParentOptions(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collectParentOptions(buildKeywordTree(keywords));
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
if (isLocked && initial) {
|
||||||
|
onSave({ ...initial, color });
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSave({ id: normalizedId, label: label.trim(), color });
|
onSave({ id: normalizedId, label: label.trim(), color });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||||
|
{nestedTags && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("parent_field")}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={parentId}
|
||||||
|
onChange={setParentId}
|
||||||
|
options={parentOptions}
|
||||||
|
disabled={isLocked}
|
||||||
|
ariaLabel={t("parent_field")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
{t("label_field")}
|
{t("label_field")}
|
||||||
@@ -141,15 +232,29 @@ function KeywordEditForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
disabled={isLocked}
|
||||||
|
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
||||||
placeholder={t("label_placeholder")}
|
placeholder={t("label_placeholder")}
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||||
/>
|
/>
|
||||||
|
{nestedTags && normalizedId.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
|
||||||
|
{KEYWORD_PREFIX + normalizedId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isLocked && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
|
||||||
|
)}
|
||||||
{isDuplicate && (
|
{isDuplicate && (
|
||||||
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
||||||
)}
|
)}
|
||||||
|
{isTooLong && (
|
||||||
|
<p className="text-xs text-destructive mt-1">
|
||||||
|
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||||
@@ -182,7 +287,7 @@ function KeywordEditForm({
|
|||||||
|
|
||||||
export function KeywordSettings() {
|
export function KeywordSettings() {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { fetchTagCounts } = useEmailStore();
|
const { fetchTagCounts } = useEmailStore();
|
||||||
@@ -260,12 +365,19 @@ export function KeywordSettings() {
|
|||||||
removeKeyword(id);
|
removeKeyword(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetDefaults = () => {
|
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||||
reorderKeywords(DEFAULT_KEYWORDS);
|
updateKeyword(id, { visibility });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t("title")} description={t("description")}>
|
<SettingsSection title={t("title")} description={t("description")}>
|
||||||
|
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={nestedTags}
|
||||||
|
onChange={(checked) => updateSetting("nestedTags", checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isMigrating && (
|
{isMigrating && (
|
||||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
||||||
@@ -278,7 +390,9 @@ export function KeywordSettings() {
|
|||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
initial={keyword}
|
initial={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleEdit}
|
onSave={handleEdit}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
/>
|
/>
|
||||||
@@ -286,11 +400,14 @@ export function KeywordSettings() {
|
|||||||
<KeywordRow
|
<KeywordRow
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingId(keyword.id);
|
setEditingId(keyword.id);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(keyword.id)}
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||||
onDragStart={() => handleDragStart(index)}
|
onDragStart={() => handleDragStart(index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
onDrop={() => handleDrop(index)}
|
onDrop={() => handleDrop(index)}
|
||||||
@@ -303,7 +420,9 @@ export function KeywordSettings() {
|
|||||||
|
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds}
|
existingIds={existingIds}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
@@ -320,14 +439,6 @@ export function KeywordSettings() {
|
|||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t("add_keyword")}
|
{t("add_keyword")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleResetDefaults}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
{t("reset_defaults")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,15 +111,24 @@ interface SelectProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[];
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ value, onChange, options }: SelectProps) {
|
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
|
||||||
|
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { useShortenedText } from '../use-shortened-text';
|
||||||
|
|
||||||
|
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports `width` for the observed element and measures text at 10px per
|
||||||
|
* character, so a width of N*10 fits any candidate of N characters or fewer.
|
||||||
|
*/
|
||||||
|
function stubMeasurement(width: number) {
|
||||||
|
// Implementing the interface rather than passing an anonymous class keeps the
|
||||||
|
// members the hook never calls from reading as dead code.
|
||||||
|
class StubResizeObserver implements ResizeObserver {
|
||||||
|
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
/** The hook observes once on mount; hand it `width` straight back. */
|
||||||
|
observe(target: Element) {
|
||||||
|
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve() {}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||||
|
font: '',
|
||||||
|
measureText: (text: string) => ({ width: text.length * 10 }),
|
||||||
|
} as unknown as CanvasRenderingContext2D);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe({ candidates }: { candidates: string[] }) {
|
||||||
|
const [ref, text] = useShortenedText(candidates);
|
||||||
|
return <span ref={ref} data-testid="probe">{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProbe(candidates: string[]): string {
|
||||||
|
render(<Probe candidates={candidates} />);
|
||||||
|
return screen.getByTestId('probe').textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useShortenedText', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the longest candidate where the DOM cannot be measured', () => {
|
||||||
|
// No ResizeObserver: server rendering, and jsdom by default. Showing the
|
||||||
|
// whole path beats shortening it on a guess.
|
||||||
|
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the full path when the element is wide enough', () => {
|
||||||
|
stubMeasurement(230);
|
||||||
|
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('steps down only as far as the width requires', () => {
|
||||||
|
stubMeasurement(200);
|
||||||
|
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the shortest candidate when none of them fit', () => {
|
||||||
|
stubMeasurement(40);
|
||||||
|
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useSettingsStore,
|
||||||
|
KEYWORD_PALETTE,
|
||||||
|
FALLBACK_KEYWORD_COLOR,
|
||||||
|
type KeywordColor,
|
||||||
|
} from "@/stores/settings-store";
|
||||||
|
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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);
|
||||||
|
const nested = useSettingsStore((state) => state.nestedTags);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
/** 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,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures text the way the browser will, using the font the element actually
|
||||||
|
* renders with. One canvas is reused for every measurement.
|
||||||
|
*/
|
||||||
|
let measureContext: CanvasRenderingContext2D | null | undefined;
|
||||||
|
|
||||||
|
function measureText(text: string, font: string): number {
|
||||||
|
if (measureContext === undefined) {
|
||||||
|
measureContext = document.createElement("canvas").getContext("2d");
|
||||||
|
}
|
||||||
|
if (!measureContext) return 0;
|
||||||
|
measureContext.font = font;
|
||||||
|
return measureContext.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the first of `candidates` that fits the element the returned ref is
|
||||||
|
* attached to, remeasuring whenever that element is resized.
|
||||||
|
*
|
||||||
|
* Candidates run longest first, so the result is the most complete one there is
|
||||||
|
* room for. A character budget cannot do this job: the columns this is used in
|
||||||
|
* are resized by the user and share their row with controls whose width depends
|
||||||
|
* on the locale, so any fixed number is either so generous that it never
|
||||||
|
* triggers or so tight that it shortens text that would have fit.
|
||||||
|
*
|
||||||
|
* Attach the ref to an element whose width does *not* depend on its own text -
|
||||||
|
* a flex child that is allowed to shrink, i.e. one with `truncate` or
|
||||||
|
* `min-w-0`. On anything else, picking a shorter candidate would change the
|
||||||
|
* width that picked it and the two would oscillate.
|
||||||
|
*
|
||||||
|
* Where measurement is unavailable - server rendering, and jsdom under test -
|
||||||
|
* this returns the first candidate, so the text is complete rather than
|
||||||
|
* arbitrarily shortened.
|
||||||
|
*/
|
||||||
|
export function useShortenedText(
|
||||||
|
candidates: string[],
|
||||||
|
): [(node: HTMLElement | null) => void, string] {
|
||||||
|
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element || typeof ResizeObserver === "undefined") return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
setBox({
|
||||||
|
width: entry.contentRect.width,
|
||||||
|
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
// Candidates are rebuilt on every render, so key the choice on their content.
|
||||||
|
// They must not contain a newline, which keeps this join unambiguous.
|
||||||
|
const key = candidates.join("\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
setElement,
|
||||||
|
useMemo(() => {
|
||||||
|
const options = key.split("\n");
|
||||||
|
if (!box || box.width === 0) return options[0];
|
||||||
|
return (
|
||||||
|
options.find((option) => measureText(option, box.font) <= box.width)
|
||||||
|
?? options[options.length - 1]
|
||||||
|
);
|
||||||
|
}, [key, box]),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
|
||||||
|
import type { Email } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
// Pin TZ so the local-time date rendering in the filename test is deterministic,
|
||||||
|
// restoring it after so this doesn't leak into other test files in the same worker.
|
||||||
|
let originalTZ: string | undefined;
|
||||||
|
beforeAll(() => {
|
||||||
|
originalTZ = process.env.TZ;
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
// process.env coerces to strings, so `= undefined` would leave the literal
|
||||||
|
// string "undefined" behind when TZ was originally unset - delete instead.
|
||||||
|
if (originalTZ === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = originalTZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
threadId: 't1',
|
||||||
|
mailboxIds: { inbox: true },
|
||||||
|
keywords: {},
|
||||||
|
size: 12345,
|
||||||
|
receivedAt: '2026-07-26T22:25:22Z',
|
||||||
|
subject: 'Your waste service day is changing',
|
||||||
|
hasAttachment: false,
|
||||||
|
blobId: 'blob123',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildForwardAsAttachmentPayload', () => {
|
||||||
|
it('returns null when the email has no blobId', () => {
|
||||||
|
const email = makeEmail({ blobId: undefined });
|
||||||
|
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes the subject using the given forward prefix', () => {
|
||||||
|
const email = makeEmail({ subject: 'Missed spam example' });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.subject).toBe('Fwd: Missed spam example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
|
||||||
|
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.attachment).toEqual({
|
||||||
|
blobId: 'the-real-blob-id',
|
||||||
|
name: expect.stringMatching(/\.eml$/),
|
||||||
|
type: 'message/rfc822',
|
||||||
|
size: 26489,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent - repeated forwarding does not stack prefixes', () => {
|
||||||
|
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.subject).toBe('Fwd: already forwarded once');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
|
||||||
|
const email = makeEmail({ subject: undefined });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.subject).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => {
|
||||||
|
const email = makeEmail({ subject: 'Missed spam example' });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
|
||||||
|
template: 'custom-{subject}',
|
||||||
|
lowercase: true,
|
||||||
|
spaceReplacement: 'dash',
|
||||||
|
});
|
||||||
|
expect(payload?.attachment.name).toBe('2026-07-26-22.25.22-missed-spam-example.eml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a dash between date and subject by default', () => {
|
||||||
|
const email = makeEmail({ subject: 'Missed spam example' });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.attachment.name).toBe('2026-07-26 22.25.22-Missed spam example.eml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never includes from/to in the filename, even with the default template, to avoid leaking names to the recipient', () => {
|
||||||
|
const email = makeEmail({
|
||||||
|
subject: 'Missed spam example',
|
||||||
|
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
|
||||||
|
to: [{ name: "'Bobby'", email: 'bob@example.com' }],
|
||||||
|
});
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.attachment.name).not.toContain('Alice');
|
||||||
|
expect(payload?.attachment.name).not.toContain('Bobby');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||||
|
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||||
|
|
||||||
|
// Work
|
||||||
|
// Clients
|
||||||
|
// Acme
|
||||||
|
// Personal
|
||||||
|
const KEYWORDS: KeywordDefinition[] = [
|
||||||
|
kw("work", "Work"),
|
||||||
|
kw("work/clients", "Clients"),
|
||||||
|
kw("work/clients/acme", "Acme"),
|
||||||
|
kw("work/personal", "Personal"),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("formatKeyword with nesting on", () => {
|
||||||
|
it("joins the display name of every level", () => {
|
||||||
|
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the plain display name for a tag with one level", () => {
|
||||||
|
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the raw level for one this client does not know", () => {
|
||||||
|
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
|
||||||
|
expect(formatKeyword("unknown", [], true)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatKeyword with nesting off", () => {
|
||||||
|
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
|
||||||
|
// The setting says a slash means nothing, so an id that happens to contain
|
||||||
|
// one - from before it was turned off, or from another client - is a single
|
||||||
|
// opaque token rather than a hierarchy.
|
||||||
|
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
|
||||||
|
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the whole id when the tag has no definition", () => {
|
||||||
|
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no shortening, leaving the markup to clip", () => {
|
||||||
|
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
|
||||||
|
.toEqual(["Acme"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keywordRenderings", () => {
|
||||||
|
it("shortens by one intermediate level at a time, outermost first", () => {
|
||||||
|
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
|
||||||
|
"Work/Clients/Acme/EU/Sales",
|
||||||
|
"Work/../Acme/EU/Sales",
|
||||||
|
"Work/.../EU/Sales",
|
||||||
|
"Work/.../Sales",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses to a single ... as soon as the run covers more than one level", () => {
|
||||||
|
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
|
||||||
|
"Work/Clients/Acme/Sales",
|
||||||
|
"Work/../Acme/Sales",
|
||||||
|
"Work/.../Sales",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses .. for a lone intermediate level, never ...", () => {
|
||||||
|
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
|
||||||
|
"Work/Clients/Acme",
|
||||||
|
"Work/../Acme",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to shorten without an intermediate level", () => {
|
||||||
|
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
|
||||||
|
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a rendering that would not come out shorter", () => {
|
||||||
|
// "../" costs as much as the level it replaces, so shortening buys nothing.
|
||||||
|
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
|
||||||
|
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// How the components use the two together: resolve a tag to its display names,
|
||||||
|
// then hand the ladder to `useShortenedText` to pick a rung.
|
||||||
|
describe("keywordRenderings over formatKeywordLabels", () => {
|
||||||
|
it("shortens a display name by the same ladder as an id", () => {
|
||||||
|
const deep: KeywordDefinition[] = [
|
||||||
|
kw("work", "Work"),
|
||||||
|
kw("work/clients", "Clients"),
|
||||||
|
kw("work/clients/acme", "Acme"),
|
||||||
|
kw("work/clients/acme/eu", "Europe"),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
|
||||||
|
"Work/Clients/Acme/Europe",
|
||||||
|
"Work/../Acme/Europe",
|
||||||
|
"Work/.../Europe",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a slash inside one display name as part of that name, not a level", () => {
|
||||||
|
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
|
||||||
|
|
||||||
|
// Two levels, so there is no intermediate level to shorten.
|
||||||
|
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
|
||||||
|
"Work/Acme/R&D",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
MAX_KEYWORD_ID_LENGTH,
|
||||||
|
buildKeywordTree,
|
||||||
|
composeKeywordId,
|
||||||
|
countKeywordNodes,
|
||||||
|
filterKeywordTree,
|
||||||
|
getParentKeywordId,
|
||||||
|
hasChildKeywords,
|
||||||
|
isKeywordDescendant,
|
||||||
|
keywordLevels,
|
||||||
|
normalizeKeywordLevel,
|
||||||
|
} from "@/lib/keyword-nesting";
|
||||||
|
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||||
|
|
||||||
|
// Work
|
||||||
|
// Clients
|
||||||
|
// Acme
|
||||||
|
// Personal
|
||||||
|
const KEYWORDS: KeywordDefinition[] = [
|
||||||
|
kw("work", "Work"),
|
||||||
|
kw("work/clients", "Clients"),
|
||||||
|
kw("work/clients/acme", "Acme"),
|
||||||
|
kw("work/personal", "Personal"),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("normalizeKeywordLevel", () => {
|
||||||
|
it("lowercases and folds unsupported characters into single dashes", () => {
|
||||||
|
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
|
||||||
|
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
|
||||||
|
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a slash as part of the name, not as a level", () => {
|
||||||
|
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string when nothing usable is left", () => {
|
||||||
|
expect(normalizeKeywordLevel(" ")).toBe("");
|
||||||
|
expect(normalizeKeywordLevel("!!!")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composeKeywordId", () => {
|
||||||
|
it("returns a bare slug at the top level", () => {
|
||||||
|
expect(composeKeywordId(null, "Work")).toBe("work");
|
||||||
|
expect(composeKeywordId("", "Work")).toBe("work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the slug below the parent", () => {
|
||||||
|
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never produces a trailing separator for an unusable name", () => {
|
||||||
|
expect(composeKeywordId("work", "!!!")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keywordLevels", () => {
|
||||||
|
it("splits an id into its levels", () => {
|
||||||
|
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
|
||||||
|
expect(keywordLevels("work")).toEqual(["work"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getParentKeywordId", () => {
|
||||||
|
it("drops the last level", () => {
|
||||||
|
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a top-level tag", () => {
|
||||||
|
expect(getParentKeywordId("work")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isKeywordDescendant", () => {
|
||||||
|
it("matches anything below the ancestor", () => {
|
||||||
|
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
|
||||||
|
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match the ancestor itself or a shared name prefix", () => {
|
||||||
|
expect(isKeywordDescendant("work", "work")).toBe(false);
|
||||||
|
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasChildKeywords", () => {
|
||||||
|
it("reports whether any defined tag sits below the given one", () => {
|
||||||
|
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
|
||||||
|
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
|
||||||
|
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MAX_KEYWORD_ID_LENGTH", () => {
|
||||||
|
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
|
||||||
|
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
|
||||||
|
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildKeywordTree", () => {
|
||||||
|
it("nests each tag under its parent and records the depth", () => {
|
||||||
|
const [work] = buildKeywordTree(KEYWORDS);
|
||||||
|
|
||||||
|
expect(work.id).toBe("work");
|
||||||
|
expect(work.depth).toBe(0);
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
|
||||||
|
|
||||||
|
const clients = work.children[0];
|
||||||
|
expect(clients.depth).toBe(1);
|
||||||
|
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||||
|
expect(clients.children[0].depth).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the manual order within a level", () => {
|
||||||
|
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
|
||||||
|
const [work] = buildKeywordTree(reordered);
|
||||||
|
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a tag whose parent is not defined at the root", () => {
|
||||||
|
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
|
||||||
|
|
||||||
|
expect(orphan).toHaveLength(1);
|
||||||
|
expect(orphan[0].id).toBe("work/clients/acme");
|
||||||
|
expect(orphan[0].depth).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns every tag as a root when no id describes a hierarchy", () => {
|
||||||
|
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
|
||||||
|
|
||||||
|
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
|
||||||
|
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterKeywordTree", () => {
|
||||||
|
const tree = buildKeywordTree(KEYWORDS);
|
||||||
|
|
||||||
|
it("drops the nodes the predicate rejects", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
|
||||||
|
const [work] = kept;
|
||||||
|
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||||
|
const [work] = kept;
|
||||||
|
|
||||||
|
expect(work.id).toBe("work");
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||||
|
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the depth of a surviving node so its indentation does not shift", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||||
|
|
||||||
|
expect(kept[0].children[0].children[0].depth).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when the predicate rejects everything", () => {
|
||||||
|
expect(filterKeywordTree(tree, () => false)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the original tree untouched", () => {
|
||||||
|
filterKeywordTree(tree, (node) => node.id === "work");
|
||||||
|
|
||||||
|
expect(countKeywordNodes(tree)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countKeywordNodes", () => {
|
||||||
|
it("counts every level, not just the roots", () => {
|
||||||
|
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
|
||||||
|
expect(countKeywordNodes([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
sortThreadGroups,
|
sortThreadGroups,
|
||||||
getThreadParticipants,
|
getThreadParticipants,
|
||||||
mergeThreadEmails,
|
mergeThreadEmails,
|
||||||
getEmailColorTag,
|
getEmailTagId,
|
||||||
getThreadColorTag,
|
getEmailTagIds,
|
||||||
|
getThreadTagId,
|
||||||
|
getThreadTagIds,
|
||||||
} from '../thread-utils';
|
} from '../thread-utils';
|
||||||
import type { Email, ThreadGroup } from '../jmap/types';
|
import type { Email, ThreadGroup } from '../jmap/types';
|
||||||
|
|
||||||
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getEmailColorTag', () => {
|
describe('getEmailTagIds', () => {
|
||||||
it('returns label from $label: keyword', () => {
|
it('gathers every tag set on the message', () => {
|
||||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
||||||
|
.toEqual(['red', 'work']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns label from legacy $color: keyword', () => {
|
it('reads the legacy prefix alongside the current one', () => {
|
||||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no color keyword', () => {
|
it('reports a tag written under both prefixes once', () => {
|
||||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for undefined keywords', () => {
|
|
||||||
expect(getEmailColorTag(undefined)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores keywords set to false', () => {
|
it('ignores keywords set to false', () => {
|
||||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers $label: over $color: when both exist', () => {
|
it('is empty for an untagged message or none at all', () => {
|
||||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
||||||
});
|
expect(getEmailTagIds(undefined)).toEqual([]);
|
||||||
|
|
||||||
it('handles custom keyword ids', () => {
|
|
||||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for empty keywords object', () => {
|
|
||||||
expect(getEmailColorTag({})).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThreadColorTag', () => {
|
describe('getEmailTagId', () => {
|
||||||
|
it('returns label from $label: keyword', () => {
|
||||||
|
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label from legacy $color: keyword', () => {
|
||||||
|
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no color keyword', () => {
|
||||||
|
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for undefined keywords', () => {
|
||||||
|
expect(getEmailTagId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores keywords set to false', () => {
|
||||||
|
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers $label: over $color: when both exist', () => {
|
||||||
|
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles custom keyword ids', () => {
|
||||||
|
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty keywords object', () => {
|
||||||
|
expect(getEmailTagId({})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getThreadTagId', () => {
|
||||||
it('returns first color found across thread emails', () => {
|
it('returns first color found across thread emails', () => {
|
||||||
const emails = [
|
const emails = [
|
||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => {
|
it('returns null when no emails have color tags', () => {
|
||||||
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||||
];
|
];
|
||||||
expect(getThreadColorTag(emails)).toBeNull();
|
expect(getThreadTagId(emails)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns first tag from earliest tagged email', () => {
|
it('returns first tag from earliest tagged email', () => {
|
||||||
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => {
|
it('returns legacy tag from thread emails', () => {
|
||||||
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$color:green': 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', () => {
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Email } from "@/lib/jmap/types";
|
||||||
|
import { buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
|
||||||
|
|
||||||
|
export interface ForwardAsAttachmentEntry {
|
||||||
|
blobId: string;
|
||||||
|
name: string;
|
||||||
|
type: "message/rfc822";
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardAsAttachmentPayload {
|
||||||
|
subject: string;
|
||||||
|
attachment: ForwardAsAttachmentEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the subject and synthetic attachment entry for forwarding a
|
||||||
|
* message as a message/rfc822 attachment instead of inline-quoted text
|
||||||
|
* (e.g. reporting spam to an upstream gateway that expects the raw
|
||||||
|
* original as an attachment, or preserving exact formatting/headers).
|
||||||
|
*
|
||||||
|
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
|
||||||
|
* not per-email, so the same blobId a message already has can be attached
|
||||||
|
* to a brand new outgoing email directly.
|
||||||
|
*
|
||||||
|
* `filenameOptions`, when passed, carries the user's configured space/case/
|
||||||
|
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
|
||||||
|
* and friends) for consistency with "Export as .eml" / drag-out. Its
|
||||||
|
* `template`, if any, is ignored: this attachment goes out to a possibly
|
||||||
|
* external recipient (spam gateway, another person), so the filename is
|
||||||
|
* always just "{date}-{subject}.eml" - never the user's own from/to naming
|
||||||
|
* template, which could otherwise leak sender/recipient names into an
|
||||||
|
* attachment filename visible to that recipient.
|
||||||
|
*
|
||||||
|
* Returns null when the email has no blobId (nothing to reference).
|
||||||
|
*/
|
||||||
|
export function buildForwardAsAttachmentPayload(
|
||||||
|
email: Email,
|
||||||
|
forwardPrefix: string,
|
||||||
|
filenameOptions?: EmailFilenameOptions,
|
||||||
|
): ForwardAsAttachmentPayload | null {
|
||||||
|
if (!email.blobId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Match the normal Forward flow's getInitialSubject(), which leaves the
|
||||||
|
// subject blank rather than prefix-only when the original has none -
|
||||||
|
// buildForwardSubject("", prefix) would otherwise return just the bare
|
||||||
|
// prefix (e.g. "Fwd:") for a subject-less message.
|
||||||
|
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
|
||||||
|
attachment: {
|
||||||
|
blobId: email.blobId,
|
||||||
|
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
|
||||||
|
type: "message/rfc822",
|
||||||
|
size: email.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Naming a tag on screen.
|
||||||
|
*
|
||||||
|
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
|
||||||
|
* one is simply its own name, so nothing here asks the caller which kind it
|
||||||
|
* has. `keywordRenderings` additionally offers progressively shorter forms for
|
||||||
|
* a name with nowhere to fit, which `useShortenedText` measures against the
|
||||||
|
* room actually available.
|
||||||
|
*/
|
||||||
|
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
|
||||||
|
|
||||||
|
/** Stands in for one level left out of a name. */
|
||||||
|
export const KEYWORD_SHORTENED_LEVEL = "..";
|
||||||
|
|
||||||
|
/** Stands in for a run of more than one level left out of a name. */
|
||||||
|
export const KEYWORD_SHORTENED_RUN = "...";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name of a tag, one entry per level, outermost first. A tag with
|
||||||
|
* one level yields a single entry, so callers need not care either way.
|
||||||
|
*
|
||||||
|
* `nested` is the user's setting. With nesting off a slash carries no meaning,
|
||||||
|
* so the id is one opaque token and the tag is named by its own label - nobody
|
||||||
|
* who left the setting alone should find their tags rewritten because an id
|
||||||
|
* happens to contain a slash, which can outlast turning nesting off, or arrive
|
||||||
|
* through settings sync or another client.
|
||||||
|
*
|
||||||
|
* With nesting on, each level resolves to that tag's display name, falling back
|
||||||
|
* to the raw level of the id when it has no definition - the settings list only
|
||||||
|
* describes the tags this client knows about. Levels stay separate entries
|
||||||
|
* because a display name may itself contain a slash, which is part of that one
|
||||||
|
* name rather than a level of its own.
|
||||||
|
*/
|
||||||
|
export function formatKeywordLabels(
|
||||||
|
id: string,
|
||||||
|
keywords: KeywordDefinition[],
|
||||||
|
nested: boolean,
|
||||||
|
): string[] {
|
||||||
|
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
|
||||||
|
if (!nested) return [label(id) ?? id];
|
||||||
|
|
||||||
|
const levels = keywordLevels(id);
|
||||||
|
return levels.map((level, index) =>
|
||||||
|
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
|
||||||
|
* otherwise. The general way to name a tag on screen.
|
||||||
|
*/
|
||||||
|
export function formatKeyword(
|
||||||
|
id: string,
|
||||||
|
keywords: KeywordDefinition[],
|
||||||
|
nested: boolean,
|
||||||
|
): string {
|
||||||
|
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every way a name can be written, longest first: in full, then with an ever
|
||||||
|
* longer run of intermediate levels replaced by `..`, collapsing to a single
|
||||||
|
* `...` as soon as that run covers more than one level.
|
||||||
|
*
|
||||||
|
* The outermost and innermost levels always survive - between them they say
|
||||||
|
* which branch a tag belongs to and which tag it is, which is exactly what a
|
||||||
|
* trailing ellipsis destroys. A rendering that would not actually come out
|
||||||
|
* shorter than the one before it (levels named `it`, say) is dropped, so
|
||||||
|
* walking the list never makes the text grow.
|
||||||
|
*/
|
||||||
|
export function keywordRenderings(levels: string[]): string[] {
|
||||||
|
const renderings = [levels.join(KEYWORD_SEPARATOR)];
|
||||||
|
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
|
||||||
|
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
|
||||||
|
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
|
||||||
|
.join(KEYWORD_SEPARATOR);
|
||||||
|
if (rendering.length < renderings[renderings.length - 1].length) {
|
||||||
|
renderings.push(rendering);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Tag nesting.
|
||||||
|
*
|
||||||
|
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
|
||||||
|
* is a slug derived from the display name. Nesting reuses that single id: the
|
||||||
|
* levels are joined with a forward slash, so `$label:work/clients` is the child
|
||||||
|
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
|
||||||
|
* the source of truth for tag membership and existing lookups by keyword keep
|
||||||
|
* working.
|
||||||
|
*
|
||||||
|
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
|
||||||
|
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
|
||||||
|
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
|
||||||
|
* composed id has to stay within.
|
||||||
|
*
|
||||||
|
* Turning any of this into text for the screen lives in `keyword-format`.
|
||||||
|
*/
|
||||||
|
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
import { KEYWORD_PREFIX } from "./thread-utils";
|
||||||
|
|
||||||
|
/** Separates parent from child inside a tag id. */
|
||||||
|
export const KEYWORD_SEPARATOR = "/";
|
||||||
|
|
||||||
|
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
|
||||||
|
export const MAX_KEYWORD_LENGTH = 255;
|
||||||
|
|
||||||
|
/** What is left for the id once the `$label:` prefix is spent. */
|
||||||
|
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
|
||||||
|
|
||||||
|
/** A tag definition placed in the hierarchy its id describes. */
|
||||||
|
export interface KeywordNode extends KeywordDefinition {
|
||||||
|
children: KeywordNode[];
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces a display name to one level of an id: lowercase, and everything
|
||||||
|
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
|
||||||
|
* a slash typed into the name is a literal part of that name, not a level.
|
||||||
|
* The only slug function for tag ids; keep it the only one.
|
||||||
|
*/
|
||||||
|
export function normalizeKeywordLevel(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
|
||||||
|
export function composeKeywordId(parentId: string | null, name: string): string {
|
||||||
|
const level = normalizeKeywordLevel(name);
|
||||||
|
if (!parentId || !level) return level;
|
||||||
|
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
|
||||||
|
export function keywordLevels(id: string): string[] {
|
||||||
|
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of the tag one level up, or null for a top-level tag. */
|
||||||
|
export function getParentKeywordId(id: string): string | null {
|
||||||
|
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
|
||||||
|
return index === -1 ? null : id.slice(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `candidateId` sits anywhere below `ancestorId`. */
|
||||||
|
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
|
||||||
|
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any defined tag sits below `id`. */
|
||||||
|
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
|
||||||
|
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arranges tag definitions into the tree their ids describe, preserving the
|
||||||
|
* user's manual order within each level.
|
||||||
|
*
|
||||||
|
* A tag whose direct parent is not defined stays at the root rather than being
|
||||||
|
* hidden or grafted onto a grandparent; callers name such a root in full so the
|
||||||
|
* missing level is still visible.
|
||||||
|
*/
|
||||||
|
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
|
||||||
|
const nodes = new Map<string, KeywordNode>();
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: KeywordNode[] = [];
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
const node = nodes.get(keyword.id)!;
|
||||||
|
const parentId = getParentKeywordId(keyword.id);
|
||||||
|
const parent = parentId ? nodes.get(parentId) : undefined;
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDepth = (node: KeywordNode, depth: number) => {
|
||||||
|
node.depth = depth;
|
||||||
|
node.children.forEach((child) => setDepth(child, depth + 1));
|
||||||
|
};
|
||||||
|
roots.forEach((root) => setDepth(root, 0));
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prunes a tag tree down to the nodes worth showing.
|
||||||
|
*
|
||||||
|
* A node survives when the predicate accepts it or when any of its descendants
|
||||||
|
* survives, so hiding a parent never strands the children below it. Depths are
|
||||||
|
* left untouched: a kept node keeps the indentation of its original level even
|
||||||
|
* when the level above it is only there to carry it.
|
||||||
|
*/
|
||||||
|
export function filterKeywordTree(
|
||||||
|
nodes: KeywordNode[],
|
||||||
|
isVisible: (node: KeywordNode) => boolean,
|
||||||
|
): KeywordNode[] {
|
||||||
|
const kept: KeywordNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const children = filterKeywordTree(node.children, isVisible);
|
||||||
|
if (children.length > 0 || isVisible(node)) {
|
||||||
|
kept.push({ ...node, children });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total number of nodes in a tag tree, at every level. */
|
||||||
|
export function countKeywordNodes(nodes: KeywordNode[]): number {
|
||||||
|
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
|
||||||
|
}
|
||||||
@@ -430,21 +430,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
|
|||||||
|
|
||||||
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
||||||
|
|
||||||
// This salt acts as a constant context identifier for key derivation.
|
|
||||||
// While hardcoded, security is maintained because the WebAuthn PRF extension
|
|
||||||
// mixes this salt with the device's unique, hardware-bound private key.
|
|
||||||
// Changing this string will result in a completely different derived secret.
|
|
||||||
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||||
* This secret is typically used as a local master encryption key.
|
* This secret is typically used as a local master encryption key.
|
||||||
*/
|
*/
|
||||||
async function doGetOrCreatePRF(
|
async function doGetOrCreatePRF(
|
||||||
masterCredentialIdBytes: number[] | undefined,
|
masterCredentialIdBytes: number[] | undefined,
|
||||||
|
pluginId: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
||||||
|
|
||||||
|
// To avoid a privileged plugin to access secret created from another privileged plugin,
|
||||||
|
// we add the pluginID from manifest in salt.
|
||||||
|
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
|
||||||
|
|
||||||
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||||
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||||
@@ -456,18 +455,18 @@ async function doGetOrCreatePRF(
|
|||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
allowCredentials: [{ type: "public-key", id: credentialId }],
|
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||||
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
||||||
extensions: { prf: { eval: { first: PRF_SALT } } } as any
|
extensions: { prf: { eval: { first: PRF_SALT } } }
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
// Extract the derived symmetric key from the authenticator's output
|
// Extract the derived symmetric key from the authenticator's output
|
||||||
const outputs = assertion.getClientExtensionResults();
|
const outputs = assertion.getClientExtensionResults();
|
||||||
const prfSecret = (outputs as any).prf?.results?.first;
|
const prfSecret = (outputs).prf?.results?.first;
|
||||||
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: masterCredentialIdBytes,
|
credentialId: masterCredentialIdBytes,
|
||||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,14 +491,14 @@ async function doGetOrCreatePRF(
|
|||||||
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||||
userVerification: "required"
|
userVerification: "required"
|
||||||
},
|
},
|
||||||
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
extensions: { prf: {} } // Request PRF extension support from the authenticator
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const outputs = credential.getClientExtensionResults();
|
const outputs = credential.getClientExtensionResults();
|
||||||
|
|
||||||
// Ensure the authenticator successfully enabled and supports the PRF extension
|
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||||
const isPrfEnabled = (outputs as any).prf?.enabled;
|
const isPrfEnabled = (outputs).prf?.enabled;
|
||||||
if (!isPrfEnabled) {
|
if (!isPrfEnabled) {
|
||||||
return 'The authenticator does not support or has rejected the PRF extension.';
|
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||||
}
|
}
|
||||||
@@ -516,20 +515,20 @@ async function doGetOrCreatePRF(
|
|||||||
userVerification: "required",
|
userVerification: "required",
|
||||||
extensions: {
|
extensions: {
|
||||||
prf: { eval: { first: PRF_SALT } }
|
prf: { eval: { first: PRF_SALT } }
|
||||||
} as any
|
}
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const assertionOutputs = assertion.getClientExtensionResults();
|
const assertionOutputs = assertion.getClientExtensionResults();
|
||||||
|
|
||||||
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
const prfSecret = (assertionOutputs).prf?.results?.first;
|
||||||
if (!prfSecret) {
|
if (!prfSecret) {
|
||||||
return 'Cannot get PRF secret from existing credential.';
|
return 'Cannot get PRF secret from existing credential.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
||||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +737,7 @@ export async function dispatchApiCall(
|
|||||||
);
|
);
|
||||||
case 'upfiles.get' : return getFile(args[0] as string);
|
case 'upfiles.get' : return getFile(args[0] as string);
|
||||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
||||||
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
|
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
|
||||||
|
|
||||||
case 'contact.get': return doContactGet(args[0] as string);
|
case 'contact.get': return doContactGet(args[0] as string);
|
||||||
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
settings: { ...manifest.settings },
|
settings: { ...manifest.settings },
|
||||||
},
|
},
|
||||||
webauthn: {
|
webauthn: {
|
||||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
|
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
get: (key: string) => callApi('storage.get', [key]),
|
get: (key: string) => callApi('storage.get', [key]),
|
||||||
|
|||||||
+30
-12
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
|
|||||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
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.
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||||
|
* A tag written under both spellings is one tag, so it is returned once.
|
||||||
*/
|
*/
|
||||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
||||||
if (!keywords) return [];
|
if (!keywords) return [];
|
||||||
const tags: string[] = [];
|
const tags = new Set<string>();
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||||
tags.push(
|
tags.add(
|
||||||
key.startsWith(KEYWORD_PREFIX)
|
key.startsWith(KEYWORD_PREFIX)
|
||||||
? key.slice(KEYWORD_PREFIX.length)
|
? key.slice(KEYWORD_PREFIX.length)
|
||||||
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tags;
|
return [...tags];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* 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 {
|
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||||
const tags = getEmailColorTags(keywords);
|
const tags = getEmailTagIds(keywords);
|
||||||
return tags.length > 0 ? tags[0] : null;
|
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) {
|
for (const email of emails) {
|
||||||
const color = getEmailColorTag(email.keywords);
|
const color = getEmailTagId(email.keywords);
|
||||||
if (color) return color;
|
if (color) return color;
|
||||||
}
|
}
|
||||||
return null;
|
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];
|
||||||
|
}
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "إعادة تعيين",
|
"demo_reset": "إعادة تعيين",
|
||||||
"demo_tour": "جولة",
|
"demo_tour": "جولة",
|
||||||
"tags": "الوسوم",
|
"tags": "الوسوم",
|
||||||
|
"show_all_tags": "إظهار الكل ({count})",
|
||||||
|
"show_fewer_tags": "إظهار أقل",
|
||||||
"folders": "المجلدات",
|
"folders": "المجلدات",
|
||||||
"shared": "مشترك",
|
"shared": "مشترك",
|
||||||
"mail": "البريد",
|
"mail": "البريد",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "طباعة",
|
"print": "طباعة",
|
||||||
"view_source": "عرض المصدر",
|
"view_source": "عرض المصدر",
|
||||||
"export_email": "تصدير كملف .eml",
|
"export_email": "تصدير كملف .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "استيراد ملف .eml أو .zip",
|
"import_email": "استيراد ملف .eml أو .zip",
|
||||||
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
|
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
|
||||||
"email_source": "مصدر الرسالة",
|
"email_source": "مصدر الرسالة",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "عرض جهة الاتصال",
|
"view_contact": "عرض جهة الاتصال",
|
||||||
"message_details": "تفاصيل الرسالة",
|
"message_details": "تفاصيل الرسالة",
|
||||||
"more_reply_options": "خيارات رد إضافية",
|
"more_reply_options": "خيارات رد إضافية",
|
||||||
"set_color": "تعيين وسم",
|
"set_tag": "تعيين وسم",
|
||||||
"tag": "وسم",
|
"tag": "وسم",
|
||||||
"more_actions": "المزيد من الإجراءات",
|
"more_actions": "المزيد من الإجراءات",
|
||||||
"previous": "السابق",
|
"previous": "السابق",
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
"move_to": "نقل إلى...",
|
"move_to": "نقل إلى...",
|
||||||
"remove_color": "إزالة الوسم",
|
"remove_tag": "إزالة الوسم",
|
||||||
|
"tag_filter_placeholder": "تصفية الوسوم",
|
||||||
|
"tag_no_matches": "لا توجد وسوم مطابقة",
|
||||||
"more_count": "+{count} أخرى",
|
"more_count": "+{count} أخرى",
|
||||||
"characters_count": "{count} حرفًا",
|
"characters_count": "{count} حرفًا",
|
||||||
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "معرّف الرسالة",
|
"message_id": "معرّف الرسالة",
|
||||||
"list_info": "معلومات القائمة"
|
"list_info": "معلومات القائمة"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "وسم لوني",
|
|
||||||
"red": "أحمر",
|
|
||||||
"orange": "برتقالي",
|
|
||||||
"yellow": "أصفر",
|
|
||||||
"green": "أخضر",
|
|
||||||
"blue": "أزرق",
|
|
||||||
"purple": "بنفسجي",
|
|
||||||
"pink": "وردي",
|
|
||||||
"none": "بلا"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "رد (r)",
|
"reply": "رد (r)",
|
||||||
"reply_all": "الرد على الجميع (a)",
|
"reply_all": "الرد على الجميع (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "وسوم البريد",
|
"title": "وسوم البريد",
|
||||||
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
|
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
|
||||||
"add_keyword": "إضافة وسم",
|
"add_keyword": "إضافة وسم",
|
||||||
"reset_defaults": "إعادة التعيين للافتراضي",
|
|
||||||
"label_field": "الاسم المعروض",
|
"label_field": "الاسم المعروض",
|
||||||
"label_placeholder": "مثال: عمل، شخصي، عاجل",
|
"label_placeholder": "مثال: عمل، شخصي، عاجل",
|
||||||
"id_field": "معرّف الوسم",
|
"id_field": "معرّف الوسم",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "إضافة",
|
"add": "إضافة",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
||||||
"migration_error": "فشل تحديث الوسم على الرسائل الحالية"
|
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
|
||||||
|
"nesting": {
|
||||||
|
"label": "وسوم متداخلة",
|
||||||
|
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
|
||||||
|
},
|
||||||
|
"parent_field": "الوسم الأصل",
|
||||||
|
"no_parent": "بدون وسم أصل",
|
||||||
|
"too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)",
|
||||||
|
"has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.",
|
||||||
|
"has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم",
|
||||||
|
"visibility_field": "الظهور في الشريط الجانبي",
|
||||||
|
"visibility": {
|
||||||
|
"show": "إظهار",
|
||||||
|
"unread": "إظهار عند وجود غير مقروء",
|
||||||
|
"hide": "إخفاء"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "اختبار صوت الإشعار",
|
"test_sound": "اختبار صوت الإشعار",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
||||||
"not_spam": "ليس مزعجًا",
|
"not_spam": "ليس مزعجًا",
|
||||||
"color_tag": "وسم",
|
"tag": "وسم",
|
||||||
"remove_color": "إزالة الوسم",
|
|
||||||
"items_selected": "{count} رسالة محددة",
|
"items_selected": "{count} رسالة محددة",
|
||||||
"edit_draft": "تعديل المسودة",
|
"edit_draft": "تعديل المسودة",
|
||||||
"cancel_scheduled_send": "إلغاء الإرسال",
|
"cancel_scheduled_send": "إلغاء الإرسال",
|
||||||
|
|||||||
+56
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reinicia",
|
"demo_reset": "Reinicia",
|
||||||
"demo_tour": "Visita guiada",
|
"demo_tour": "Visita guiada",
|
||||||
"tags": "Etiquetes",
|
"tags": "Etiquetes",
|
||||||
|
"show_all_tags": "Mostra-ho tot ({count})",
|
||||||
|
"show_fewer_tags": "Mostra'n menys",
|
||||||
"folders": "Carpetes",
|
"folders": "Carpetes",
|
||||||
"shared": "Compartit",
|
"shared": "Compartit",
|
||||||
"mail": "Correu",
|
"mail": "Correu",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimeix",
|
"print": "Imprimeix",
|
||||||
"view_source": "Mostra el codi font",
|
"view_source": "Mostra el codi font",
|
||||||
"export_email": "Exporta com a .eml",
|
"export_email": "Exporta com a .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importa .eml o .zip",
|
"import_email": "Importa .eml o .zip",
|
||||||
"keyboard_shortcuts": "Dreceres de teclat (?)",
|
"keyboard_shortcuts": "Dreceres de teclat (?)",
|
||||||
"email_source": "Codi font del correu",
|
"email_source": "Codi font del correu",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Mostra el contacte",
|
"view_contact": "Mostra el contacte",
|
||||||
"message_details": "Detalls del missatge",
|
"message_details": "Detalls del missatge",
|
||||||
"more_reply_options": "Més opcions de resposta",
|
"more_reply_options": "Més opcions de resposta",
|
||||||
"set_color": "Estableix l'etiqueta",
|
"set_tag": "Estableix l'etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Més accions",
|
"more_actions": "Més accions",
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Següent",
|
"next": "Següent",
|
||||||
"move_to": "Mou a...",
|
"move_to": "Mou a...",
|
||||||
"remove_color": "Elimina l'etiqueta",
|
"remove_tag": "Elimina l'etiqueta",
|
||||||
|
"tag_filter_placeholder": "Filtra les etiquetes",
|
||||||
|
"tag_no_matches": "Cap etiqueta coincident",
|
||||||
"more_count": "+{count} més",
|
"more_count": "+{count} més",
|
||||||
"characters_count": "{count} caràcters",
|
"characters_count": "{count} caràcters",
|
||||||
"quick_reply_placeholder": "Escriviu una resposta ràpida...",
|
"quick_reply_placeholder": "Escriviu una resposta ràpida...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "ID del missatge",
|
"message_id": "ID del missatge",
|
||||||
"list_info": "Informació de la llista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Respon (r)",
|
"reply": "Respon (r)",
|
||||||
"reply_all": "Respon a tots (a)",
|
"reply_all": "Respon a tots (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Nom mostrat",
|
"recipient_name_placeholder": "Nom mostrat",
|
||||||
"autocomplete_search_server": "Cerca al servidor",
|
"autocomplete_search_server": "Cerca al servidor",
|
||||||
"autocomplete_searching": "Cercant...",
|
"autocomplete_searching": "Cercant...",
|
||||||
|
"toolbar": {
|
||||||
|
"bold": "Negreta",
|
||||||
|
"italic": "Cursiva",
|
||||||
|
"underline": "Subratllat",
|
||||||
|
"strikethrough": "Ratllat",
|
||||||
|
"text_color": "Color del text",
|
||||||
|
"remove_color": "Elimina el color",
|
||||||
|
"heading_1": "Encapçalament 1",
|
||||||
|
"heading_2": "Encapçalament 2",
|
||||||
|
"bullet_list": "Llista de pics",
|
||||||
|
"ordered_list": "Llista numerada",
|
||||||
|
"quote": "Cita",
|
||||||
|
"code_block": "Bloc de codi",
|
||||||
|
"align_left": "Alinea a l'esquerra",
|
||||||
|
"align_center": "Centra",
|
||||||
|
"align_right": "Alinea a la dreta",
|
||||||
|
"text_direction": "Direcció del text (RTL/LTR)",
|
||||||
|
"link": "Enllaç",
|
||||||
|
"table": "Taula",
|
||||||
|
"clear_formatting": "Neteja el format",
|
||||||
|
"undo": "Desfés",
|
||||||
|
"redo": "Refés",
|
||||||
|
"add_row_above": "Afegeix una fila a sobre",
|
||||||
|
"add_row_below": "Afegeix una fila a sota",
|
||||||
|
"add_column_before": "Afegeix una columna abans",
|
||||||
|
"add_column_after": "Afegeix una columna després",
|
||||||
|
"delete_row": "Elimina la fila",
|
||||||
|
"delete_column": "Elimina la columna",
|
||||||
|
"toggle_header_row": "Commuta la fila de capçalera",
|
||||||
|
"delete_table": "Elimina la taula",
|
||||||
|
"pick_size": "Tria la mida"
|
||||||
|
},
|
||||||
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
|
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "Etiquetes de correu",
|
"title": "Etiquetes de correu",
|
||||||
"description": "Definiu etiquetes per organitzar els correus amb colors. Es desen com a paraules clau JMAP al servidor.",
|
"description": "Definiu etiquetes per organitzar els correus amb colors. Es desen com a paraules clau JMAP al servidor.",
|
||||||
"add_keyword": "Afegeix una etiqueta",
|
"add_keyword": "Afegeix una etiqueta",
|
||||||
"reset_defaults": "Restableix als valors per defecte",
|
|
||||||
"label_field": "Nom mostrat",
|
"label_field": "Nom mostrat",
|
||||||
"label_placeholder": "p. ex. Feina, Personal, Urgent",
|
"label_placeholder": "p. ex. Feina, Personal, Urgent",
|
||||||
"id_field": "ID de l'etiqueta",
|
"id_field": "ID de l'etiqueta",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Afegeix",
|
"add": "Afegeix",
|
||||||
"cancel": "Cancel·la",
|
"cancel": "Cancel·la",
|
||||||
"migrating": "Actualitzant l'etiqueta als correus existents…",
|
"migrating": "Actualitzant l'etiqueta als correus existents…",
|
||||||
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents"
|
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Etiquetes imbricades",
|
||||||
|
"description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral."
|
||||||
|
},
|
||||||
|
"parent_field": "Etiqueta principal",
|
||||||
|
"no_parent": "Sense etiqueta principal",
|
||||||
|
"too_long": "Aquest camí d'etiqueta és massa llarg (com a màxim {max} caràcters)",
|
||||||
|
"has_children_locked": "Hi ha altres etiquetes imbricades sota aquesta, per això el seu nom i la seva etiqueta principal estan bloquejats. Mou-les o elimina-les primer.",
|
||||||
|
"has_children_delete": "Elimina primer les etiquetes imbricades sota aquesta",
|
||||||
|
"visibility_field": "Visibilitat a la barra lateral",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Mostra",
|
||||||
|
"unread": "Mostra si hi ha no llegits",
|
||||||
|
"hide": "Amaga"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Prova el so de notificació",
|
"test_sound": "Prova el so de notificació",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Suprimeix",
|
"delete": "Suprimeix",
|
||||||
"mark_as_spam": "Denuncia com a brossa",
|
"mark_as_spam": "Denuncia com a brossa",
|
||||||
"not_spam": "No és brossa",
|
"not_spam": "No és brossa",
|
||||||
"color_tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"remove_color": "Elimina l'etiqueta",
|
|
||||||
"items_selected": "{count} correus seleccionats",
|
"items_selected": "{count} correus seleccionats",
|
||||||
"edit_draft": "Edita l'esborrany",
|
"edit_draft": "Edita l'esborrany",
|
||||||
"cancel_scheduled_send": "Cancel·la l'enviament",
|
"cancel_scheduled_send": "Cancel·la l'enviament",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetovat",
|
"demo_reset": "Resetovat",
|
||||||
"demo_tour": "Průvodce",
|
"demo_tour": "Průvodce",
|
||||||
"tags": "Štítky",
|
"tags": "Štítky",
|
||||||
|
"show_all_tags": "Zobrazit vše ({count})",
|
||||||
|
"show_fewer_tags": "Zobrazit méně",
|
||||||
"folders": "Složky",
|
"folders": "Složky",
|
||||||
"shared": "Sdílené",
|
"shared": "Sdílené",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Tisk",
|
"print": "Tisk",
|
||||||
"view_source": "Zobrazit zdrojový kód",
|
"view_source": "Zobrazit zdrojový kód",
|
||||||
"export_email": "Exportovat jako .eml",
|
"export_email": "Exportovat jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovat .eml nebo .zip",
|
"import_email": "Importovat .eml nebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
||||||
"email_source": "Zdrojový kód zprávy",
|
"email_source": "Zdrojový kód zprávy",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Zobrazit kontakt",
|
"view_contact": "Zobrazit kontakt",
|
||||||
"message_details": "Podrobnosti zprávy",
|
"message_details": "Podrobnosti zprávy",
|
||||||
"more_reply_options": "Další možnosti odpovědi",
|
"more_reply_options": "Další možnosti odpovědi",
|
||||||
"set_color": "Nastavit štítek",
|
"set_tag": "Nastavit štítek",
|
||||||
"tag": "Štítek",
|
"tag": "Štítek",
|
||||||
"more_actions": "Další akce",
|
"more_actions": "Další akce",
|
||||||
"previous": "Předchozí",
|
"previous": "Předchozí",
|
||||||
"next": "Další",
|
"next": "Další",
|
||||||
"move_to": "Přesunout do...",
|
"move_to": "Přesunout do...",
|
||||||
"remove_color": "Odebrat štítek",
|
"remove_tag": "Odebrat štítek",
|
||||||
|
"tag_filter_placeholder": "Filtrovat štítky",
|
||||||
|
"tag_no_matches": "Žádné odpovídající štítky",
|
||||||
"more_count": "+{count} dalších",
|
"more_count": "+{count} dalších",
|
||||||
"characters_count": "{count} znaků",
|
"characters_count": "{count} znaků",
|
||||||
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID zprávy",
|
"message_id": "ID zprávy",
|
||||||
"list_info": "Informace o konferenci"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpovědět (r)",
|
"reply": "Odpovědět (r)",
|
||||||
"reply_all": "Odpovědět všem (a)",
|
"reply_all": "Odpovědět všem (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "E-mailové štítky",
|
"title": "E-mailové štítky",
|
||||||
"description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
|
"description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
|
||||||
"add_keyword": "Přidat štítek",
|
"add_keyword": "Přidat štítek",
|
||||||
"reset_defaults": "Obnovit výchozí",
|
|
||||||
"label_field": "Zobrazovaný název",
|
"label_field": "Zobrazovaný název",
|
||||||
"label_placeholder": "např. Práce, Osobní, Naliehavé",
|
"label_placeholder": "např. Práce, Osobní, Naliehavé",
|
||||||
"id_field": "ID štítku",
|
"id_field": "ID štítku",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Přidat",
|
"add": "Přidat",
|
||||||
"cancel": "Zrušit",
|
"cancel": "Zrušit",
|
||||||
"migrating": "Aktualizace štítku v existujících e-mailech…",
|
"migrating": "Aktualizace štítku v existujících e-mailech…",
|
||||||
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech"
|
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Vnořené štítky",
|
||||||
|
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
|
||||||
|
},
|
||||||
|
"parent_field": "Nadřazený štítek",
|
||||||
|
"no_parent": "Bez nadřazeného štítku",
|
||||||
|
"too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)",
|
||||||
|
"has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.",
|
||||||
|
"has_children_delete": "Nejprve odeberte štítky vnořené pod tímto",
|
||||||
|
"visibility_field": "Viditelnost v postranním panelu",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Zobrazit",
|
||||||
|
"unread": "Zobrazit při nepřečtených",
|
||||||
|
"hide": "Skrýt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Otestovat zvuk oznámení",
|
"test_sound": "Otestovat zvuk oznámení",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
"mark_as_spam": "Nahlásit spam",
|
"mark_as_spam": "Nahlásit spam",
|
||||||
"not_spam": "Není spam",
|
"not_spam": "Není spam",
|
||||||
"color_tag": "Štítek",
|
"tag": "Štítek",
|
||||||
"remove_color": "Odebrat štítek",
|
|
||||||
"items_selected": "{count} vybraných zpráv",
|
"items_selected": "{count} vybraných zpráv",
|
||||||
"edit_draft": "Upravit koncept",
|
"edit_draft": "Upravit koncept",
|
||||||
"cancel_scheduled_send": "Zrušit odeslání",
|
"cancel_scheduled_send": "Zrušit odeslání",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Nulstil",
|
"demo_reset": "Nulstil",
|
||||||
"demo_tour": "Rundvisning",
|
"demo_tour": "Rundvisning",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Vis alle ({count})",
|
||||||
|
"show_fewer_tags": "Vis færre",
|
||||||
"folders": "Mapper",
|
"folders": "Mapper",
|
||||||
"shared": "Delt",
|
"shared": "Delt",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Udskriv",
|
"print": "Udskriv",
|
||||||
"view_source": "Vis kilde",
|
"view_source": "Vis kilde",
|
||||||
"export_email": "Eksportér som .eml",
|
"export_email": "Eksportér som .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importér .eml eller .zip",
|
"import_email": "Importér .eml eller .zip",
|
||||||
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
||||||
"email_source": "E-mail-kilde",
|
"email_source": "E-mail-kilde",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Vis kontakt",
|
"view_contact": "Vis kontakt",
|
||||||
"message_details": "Beskeddetaljer",
|
"message_details": "Beskeddetaljer",
|
||||||
"more_reply_options": "Flere svar-muligheder",
|
"more_reply_options": "Flere svar-muligheder",
|
||||||
"set_color": "Sæt tag",
|
"set_tag": "Sæt tag",
|
||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"more_actions": "Flere handlinger",
|
"more_actions": "Flere handlinger",
|
||||||
"previous": "Forrige",
|
"previous": "Forrige",
|
||||||
"next": "Næste",
|
"next": "Næste",
|
||||||
"move_to": "Flyt til...",
|
"move_to": "Flyt til...",
|
||||||
"remove_color": "Fjern tag",
|
"remove_tag": "Fjern tag",
|
||||||
|
"tag_filter_placeholder": "Filtrer tags",
|
||||||
|
"tag_no_matches": "Ingen matchende tags",
|
||||||
"more_count": "+{count} mere",
|
"more_count": "+{count} mere",
|
||||||
"characters_count": "{count} tegn",
|
"characters_count": "{count} tegn",
|
||||||
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Besked-ID",
|
"message_id": "Besked-ID",
|
||||||
"list_info": "Listeinformation"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Svar (r)",
|
"reply": "Svar (r)",
|
||||||
"reply_all": "Svar alle (a)",
|
"reply_all": "Svar alle (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "E-mail-tags",
|
"title": "E-mail-tags",
|
||||||
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
|
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
|
||||||
"add_keyword": "Tilføj tag",
|
"add_keyword": "Tilføj tag",
|
||||||
"reset_defaults": "Nulstil til standard",
|
|
||||||
"label_field": "Visningsnavn",
|
"label_field": "Visningsnavn",
|
||||||
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
|
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
|
||||||
"id_field": "Tag-ID",
|
"id_field": "Tag-ID",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "Tilføj",
|
"add": "Tilføj",
|
||||||
"cancel": "Annuller",
|
"cancel": "Annuller",
|
||||||
"migrating": "Opdaterer tag på eksisterende e-mails…",
|
"migrating": "Opdaterer tag på eksisterende e-mails…",
|
||||||
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails"
|
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Indlejrede tags",
|
||||||
|
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
|
||||||
|
},
|
||||||
|
"parent_field": "Overordnet tag",
|
||||||
|
"no_parent": "Intet overordnet tag",
|
||||||
|
"too_long": "Denne tagsti er for lang (højst {max} tegn)",
|
||||||
|
"has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.",
|
||||||
|
"has_children_delete": "Fjern først de tags, der er indlejret under dette",
|
||||||
|
"visibility_field": "Synlighed i sidepanel",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Vis",
|
||||||
|
"unread": "Vis ved ulæste",
|
||||||
|
"hide": "Skjul"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Test notifikationslyd",
|
"test_sound": "Test notifikationslyd",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
"mark_as_spam": "Rapportér spam",
|
"mark_as_spam": "Rapportér spam",
|
||||||
"not_spam": "Ikke spam",
|
"not_spam": "Ikke spam",
|
||||||
"color_tag": "Tag",
|
"tag": "Tag",
|
||||||
"remove_color": "Fjern tag",
|
|
||||||
"items_selected": "{count} e-mails valgt",
|
"items_selected": "{count} e-mails valgt",
|
||||||
"edit_draft": "Redigér kladde",
|
"edit_draft": "Redigér kladde",
|
||||||
"cancel_scheduled_send": "Annuller afsendelse",
|
"cancel_scheduled_send": "Annuller afsendelse",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Zurücksetzen",
|
"demo_reset": "Zurücksetzen",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Alle anzeigen ({count})",
|
||||||
|
"show_fewer_tags": "Weniger anzeigen",
|
||||||
"folders": "Ordner",
|
"folders": "Ordner",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"view_source": "Quelltext anzeigen",
|
"view_source": "Quelltext anzeigen",
|
||||||
"export_email": "Als .eml exportieren",
|
"export_email": "Als .eml exportieren",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml oder .zip importieren",
|
"import_email": ".eml oder .zip importieren",
|
||||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||||
"email_source": "E-Mail-Quelltext",
|
"email_source": "E-Mail-Quelltext",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Kontakt anzeigen",
|
"view_contact": "Kontakt anzeigen",
|
||||||
"message_details": "Nachrichtendetails",
|
"message_details": "Nachrichtendetails",
|
||||||
"more_reply_options": "Weitere Antwortoptionen",
|
"more_reply_options": "Weitere Antwortoptionen",
|
||||||
"set_color": "Label setzen",
|
"set_tag": "Label setzen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Weitere Aktionen",
|
"more_actions": "Weitere Aktionen",
|
||||||
"move_to": "Verschieben nach...",
|
"move_to": "Verschieben nach...",
|
||||||
"remove_color": "Label entfernen",
|
"remove_tag": "Label entfernen",
|
||||||
|
"tag_filter_placeholder": "Labels filtern",
|
||||||
|
"tag_no_matches": "Keine passenden Labels",
|
||||||
"more_count": "+{count} weitere",
|
"more_count": "+{count} weitere",
|
||||||
"characters_count": "{count} Zeichen",
|
"characters_count": "{count} Zeichen",
|
||||||
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "Nachrichten-ID",
|
"message_id": "Nachrichten-ID",
|
||||||
"list_info": "Listeninformationen"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Antworten",
|
"reply": "Antworten",
|
||||||
"reply_all": "Allen antworten (a)",
|
"reply_all": "Allen antworten (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "E-Mail-Labels",
|
"title": "E-Mail-Labels",
|
||||||
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
|
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
|
||||||
"add_keyword": "Label hinzufügen",
|
"add_keyword": "Label hinzufügen",
|
||||||
"reset_defaults": "Auf Standard zurücksetzen",
|
|
||||||
"label_field": "Anzeigename",
|
"label_field": "Anzeigename",
|
||||||
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
||||||
"id_field": "Label-ID",
|
"id_field": "Label-ID",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
||||||
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
|
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Verschachtelte Labels",
|
||||||
|
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
|
||||||
|
},
|
||||||
|
"parent_field": "Übergeordnetes Label",
|
||||||
|
"no_parent": "Kein übergeordnetes Label",
|
||||||
|
"too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)",
|
||||||
|
"has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.",
|
||||||
|
"has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind",
|
||||||
|
"visibility_field": "Sichtbarkeit in der Seitenleiste",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Anzeigen",
|
||||||
|
"unread": "Bei Ungelesenen anzeigen",
|
||||||
|
"hide": "Ausblenden"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Benachrichtigungston testen",
|
"test_sound": "Benachrichtigungston testen",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"mark_as_spam": "Spam melden",
|
"mark_as_spam": "Spam melden",
|
||||||
"not_spam": "Kein Spam",
|
"not_spam": "Kein Spam",
|
||||||
"color_tag": "Label",
|
"tag": "Label",
|
||||||
"remove_color": "Label entfernen",
|
|
||||||
"items_selected": "{count} E-Mails ausgewählt",
|
"items_selected": "{count} E-Mails ausgewählt",
|
||||||
"edit_draft": "Entwurf bearbeiten",
|
"edit_draft": "Entwurf bearbeiten",
|
||||||
"cancel_scheduled_send": "Senden abbrechen",
|
"cancel_scheduled_send": "Senden abbrechen",
|
||||||
|
|||||||
+25
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reset",
|
"demo_reset": "Reset",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Show all ({count})",
|
||||||
|
"show_fewer_tags": "Show less",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Print",
|
"print": "Print",
|
||||||
"view_source": "View source",
|
"view_source": "View source",
|
||||||
"export_email": "Export as .eml",
|
"export_email": "Export as .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Import .eml or .zip",
|
"import_email": "Import .eml or .zip",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||||
"email_source": "Email Source",
|
"email_source": "Email Source",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "View contact",
|
"view_contact": "View contact",
|
||||||
"message_details": "Message Details",
|
"message_details": "Message Details",
|
||||||
"more_reply_options": "More reply options",
|
"more_reply_options": "More reply options",
|
||||||
"set_color": "Set tag",
|
"set_tag": "Set tag",
|
||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"more_actions": "More actions",
|
"more_actions": "More actions",
|
||||||
"previous": "Prev",
|
"previous": "Prev",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"move_to": "Move to...",
|
"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",
|
"more_count": "+{count} more",
|
||||||
"characters_count": "{count} characters",
|
"characters_count": "{count} characters",
|
||||||
"quick_reply_placeholder": "Write a quick reply...",
|
"quick_reply_placeholder": "Write a quick reply...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Message ID",
|
"message_id": "Message ID",
|
||||||
"list_info": "List Information"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Reply (r)",
|
"reply": "Reply (r)",
|
||||||
"reply_all": "Reply All (a)",
|
"reply_all": "Reply All (a)",
|
||||||
@@ -1016,9 +1010,8 @@
|
|||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"title": "Email Tags",
|
"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",
|
"add_keyword": "Add Tag",
|
||||||
"reset_defaults": "Reset to Defaults",
|
|
||||||
"label_field": "Display Name",
|
"label_field": "Display Name",
|
||||||
"label_placeholder": "e.g. Work, Personal, Urgent",
|
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||||
"id_field": "Tag ID",
|
"id_field": "Tag ID",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"migrating": "Updating tag on existing emails…",
|
"migrating": "Updating tag on existing emails…",
|
||||||
"migration_error": "Failed to update tag on existing emails"
|
"migration_error": "Failed to update tag on existing emails",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Nested Tags",
|
||||||
|
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
|
||||||
|
},
|
||||||
|
"parent_field": "Parent Tag",
|
||||||
|
"no_parent": "No parent",
|
||||||
|
"too_long": "This tag path is too long (at most {max} characters)",
|
||||||
|
"has_children_locked": "Other tags are nested under this one, so its name and parent are locked. Move or remove them first.",
|
||||||
|
"has_children_delete": "Remove the tags nested under this one first",
|
||||||
|
"visibility_field": "Sidebar visibility",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Show",
|
||||||
|
"unread": "Show if unread",
|
||||||
|
"hide": "Hide"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Test notification sound",
|
"test_sound": "Test notification sound",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"mark_as_spam": "Report spam",
|
"mark_as_spam": "Report spam",
|
||||||
"not_spam": "Not spam",
|
"not_spam": "Not spam",
|
||||||
"color_tag": "Tag",
|
"tag": "Tag",
|
||||||
"remove_color": "Remove tag",
|
|
||||||
"items_selected": "{count} emails selected",
|
"items_selected": "{count} emails selected",
|
||||||
"edit_draft": "Edit Draft",
|
"edit_draft": "Edit Draft",
|
||||||
"cancel_scheduled_send": "Cancel send",
|
"cancel_scheduled_send": "Cancel send",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Restablecer",
|
"demo_reset": "Restablecer",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"show_all_tags": "Mostrar todo ({count})",
|
||||||
|
"show_fewer_tags": "Mostrar menos",
|
||||||
"folders": "Carpetas",
|
"folders": "Carpetas",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código fuente",
|
"view_source": "Ver código fuente",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml o .zip",
|
"import_email": "Importar .eml o .zip",
|
||||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||||
"email_source": "Código Fuente del Correo",
|
"email_source": "Código Fuente del Correo",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Ver contacto",
|
"view_contact": "Ver contacto",
|
||||||
"message_details": "Detalles del Mensaje",
|
"message_details": "Detalles del Mensaje",
|
||||||
"more_reply_options": "Más opciones de respuesta",
|
"more_reply_options": "Más opciones de respuesta",
|
||||||
"set_color": "Establecer etiqueta",
|
"set_tag": "Establecer etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Más acciones",
|
"more_actions": "Más acciones",
|
||||||
"move_to": "Mover a...",
|
"move_to": "Mover a...",
|
||||||
"remove_color": "Eliminar etiqueta",
|
"remove_tag": "Eliminar etiqueta",
|
||||||
|
"tag_filter_placeholder": "Filtrar etiquetas",
|
||||||
|
"tag_no_matches": "No hay etiquetas coincidentes",
|
||||||
"more_count": "+{count} más",
|
"more_count": "+{count} más",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID del Mensaje",
|
"message_id": "ID del Mensaje",
|
||||||
"list_info": "Información de Lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
"reply_all": "Responder a todos (a)",
|
"reply_all": "Responder a todos (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Etiquetas de correo",
|
"title": "Etiquetas de correo",
|
||||||
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
||||||
"add_keyword": "Añadir etiqueta",
|
"add_keyword": "Añadir etiqueta",
|
||||||
"reset_defaults": "Restablecer valores predeterminados",
|
|
||||||
"label_field": "Nombre para mostrar",
|
"label_field": "Nombre para mostrar",
|
||||||
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
||||||
"id_field": "ID de etiqueta",
|
"id_field": "ID de etiqueta",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"migrating": "Actualizando etiqueta en correos existentes…",
|
"migrating": "Actualizando etiqueta en correos existentes…",
|
||||||
"migration_error": "Error al actualizar la etiqueta en correos existentes"
|
"migration_error": "Error al actualizar la etiqueta en correos existentes",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Etiquetas anidadas",
|
||||||
|
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
|
||||||
|
},
|
||||||
|
"parent_field": "Etiqueta principal",
|
||||||
|
"no_parent": "Sin etiqueta principal",
|
||||||
|
"too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)",
|
||||||
|
"has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.",
|
||||||
|
"has_children_delete": "Elimina primero las etiquetas anidadas bajo esta",
|
||||||
|
"visibility_field": "Visibilidad en la barra lateral",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Mostrar",
|
||||||
|
"unread": "Mostrar si hay no leídos",
|
||||||
|
"hide": "Ocultar"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Probar sonido de notificación",
|
"test_sound": "Probar sonido de notificación",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"mark_as_spam": "Reportar spam",
|
"mark_as_spam": "Reportar spam",
|
||||||
"not_spam": "No es spam",
|
"not_spam": "No es spam",
|
||||||
"color_tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"remove_color": "Eliminar etiqueta",
|
|
||||||
"items_selected": "{count} correos seleccionados",
|
"items_selected": "{count} correos seleccionados",
|
||||||
"edit_draft": "Editar borrador",
|
"edit_draft": "Editar borrador",
|
||||||
"cancel_scheduled_send": "Cancelar envío",
|
"cancel_scheduled_send": "Cancelar envío",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "بازنشانی",
|
"demo_reset": "بازنشانی",
|
||||||
"demo_tour": "تور",
|
"demo_tour": "تور",
|
||||||
"tags": "برچسبها",
|
"tags": "برچسبها",
|
||||||
|
"show_all_tags": "نمایش همه ({count})",
|
||||||
|
"show_fewer_tags": "نمایش کمتر",
|
||||||
"folders": "پوشهها",
|
"folders": "پوشهها",
|
||||||
"shared": "اشتراکی",
|
"shared": "اشتراکی",
|
||||||
"mail": "ایمیل",
|
"mail": "ایمیل",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "چاپ",
|
"print": "چاپ",
|
||||||
"view_source": "مشاهده منبع",
|
"view_source": "مشاهده منبع",
|
||||||
"export_email": "خروجی .eml",
|
"export_email": "خروجی .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "وارد کردن .eml یا .zip",
|
"import_email": "وارد کردن .eml یا .zip",
|
||||||
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
|
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
|
||||||
"email_source": "منبع ایمیل",
|
"email_source": "منبع ایمیل",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "مشاهده مخاطب",
|
"view_contact": "مشاهده مخاطب",
|
||||||
"message_details": "جزئیات پیام",
|
"message_details": "جزئیات پیام",
|
||||||
"more_reply_options": "گزینههای بیشتر پاسخ",
|
"more_reply_options": "گزینههای بیشتر پاسخ",
|
||||||
"set_color": "تنظیم برچسب",
|
"set_tag": "تنظیم برچسب",
|
||||||
"tag": "برچسب",
|
"tag": "برچسب",
|
||||||
"more_actions": "عملیات بیشتر",
|
"more_actions": "عملیات بیشتر",
|
||||||
"previous": "قبلی",
|
"previous": "قبلی",
|
||||||
"next": "بعدی",
|
"next": "بعدی",
|
||||||
"move_to": "انتقال به...",
|
"move_to": "انتقال به...",
|
||||||
"remove_color": "حذف برچسب",
|
"remove_tag": "حذف برچسب",
|
||||||
|
"tag_filter_placeholder": "فیلتر برچسبها",
|
||||||
|
"tag_no_matches": "برچسب مطابقی یافت نشد",
|
||||||
"more_count": "+{count} بیشتر",
|
"more_count": "+{count} بیشتر",
|
||||||
"characters_count": "{count} کاراکتر",
|
"characters_count": "{count} کاراکتر",
|
||||||
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "شناسه پیام",
|
"message_id": "شناسه پیام",
|
||||||
"list_info": "اطلاعات لیست"
|
"list_info": "اطلاعات لیست"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "برچسب رنگی",
|
|
||||||
"red": "قرمز",
|
|
||||||
"orange": "نارنجی",
|
|
||||||
"yellow": "زرد",
|
|
||||||
"green": "سبز",
|
|
||||||
"blue": "آبی",
|
|
||||||
"purple": "بنفش",
|
|
||||||
"pink": "صورتی",
|
|
||||||
"none": "هیچکدام"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "پاسخ (r)",
|
"reply": "پاسخ (r)",
|
||||||
"reply_all": "پاسخ به همه (a)",
|
"reply_all": "پاسخ به همه (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "برچسبهای ایمیل",
|
"title": "برچسبهای ایمیل",
|
||||||
"description": "برچسبها را برای سازماندهی ایمیلها تعریف کنید",
|
"description": "برچسبها را برای سازماندهی ایمیلها تعریف کنید",
|
||||||
"add_keyword": "افزودن برچسب",
|
"add_keyword": "افزودن برچسب",
|
||||||
"reset_defaults": "بازنشانی به پیشفرض",
|
|
||||||
"label_field": "نام نمایشی",
|
"label_field": "نام نمایشی",
|
||||||
"label_placeholder": "مثال: کاری، شخصی، فوری",
|
"label_placeholder": "مثال: کاری، شخصی، فوری",
|
||||||
"id_field": "شناسه برچسب",
|
"id_field": "شناسه برچسب",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
||||||
"migration_error": "بهروزرسانی برچسب ناموفق بود"
|
"migration_error": "بهروزرسانی برچسب ناموفق بود",
|
||||||
|
"nesting": {
|
||||||
|
"label": "برچسبهای تودرتو",
|
||||||
|
"description": "برچسبها را زیر برچسبهای دیگر قرار دهید و آنها را بهصورت درختی در نوار کناری نمایش دهید."
|
||||||
|
},
|
||||||
|
"parent_field": "برچسب والد",
|
||||||
|
"no_parent": "بدون برچسب والد",
|
||||||
|
"too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)",
|
||||||
|
"has_children_locked": "برچسبهای دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آنها را جابهجا یا حذف کنید.",
|
||||||
|
"has_children_delete": "ابتدا برچسبهای زیرمجموعهٔ این برچسب را حذف کنید",
|
||||||
|
"visibility_field": "نمایش در نوار کناری",
|
||||||
|
"visibility": {
|
||||||
|
"show": "نمایش",
|
||||||
|
"unread": "نمایش در صورت وجود خواندهنشده",
|
||||||
|
"hide": "پنهان کردن"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "تست صدای اعلان",
|
"test_sound": "تست صدای اعلان",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"mark_as_spam": "گزارش هرزنامه",
|
"mark_as_spam": "گزارش هرزنامه",
|
||||||
"not_spam": "هرزنامه نیست",
|
"not_spam": "هرزنامه نیست",
|
||||||
"color_tag": "برچسب",
|
"tag": "برچسب",
|
||||||
"remove_color": "حذف برچسب",
|
|
||||||
"items_selected": "{count} ایمیل انتخاب شده",
|
"items_selected": "{count} ایمیل انتخاب شده",
|
||||||
"edit_draft": "ویرایش پیشنویس",
|
"edit_draft": "ویرایش پیشنویس",
|
||||||
"cancel_scheduled_send": "لغو ارسال",
|
"cancel_scheduled_send": "لغو ارسال",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Réinitialiser",
|
"demo_reset": "Réinitialiser",
|
||||||
"demo_tour": "Visite",
|
"demo_tour": "Visite",
|
||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
|
"show_all_tags": "Tout afficher ({count})",
|
||||||
|
"show_fewer_tags": "Afficher moins",
|
||||||
"folders": "Dossiers",
|
"folders": "Dossiers",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
"view_source": "Voir la source",
|
"view_source": "Voir la source",
|
||||||
"export_email": "Exporter en .eml",
|
"export_email": "Exporter en .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importer .eml ou .zip",
|
"import_email": "Importer .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||||
"email_source": "Source de l'email",
|
"email_source": "Source de l'email",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Voir le contact",
|
"view_contact": "Voir le contact",
|
||||||
"message_details": "Détails du message",
|
"message_details": "Détails du message",
|
||||||
"more_reply_options": "Plus d'options de réponse",
|
"more_reply_options": "Plus d'options de réponse",
|
||||||
"set_color": "Définir l'étiquette",
|
"set_tag": "Définir l'étiquette",
|
||||||
"tag": "Étiquette",
|
"tag": "Étiquette",
|
||||||
"more_actions": "Plus d'actions",
|
"more_actions": "Plus d'actions",
|
||||||
"move_to": "Déplacer vers...",
|
"move_to": "Déplacer vers...",
|
||||||
"remove_color": "Retirer l'étiquette",
|
"remove_tag": "Retirer l'étiquette",
|
||||||
|
"tag_filter_placeholder": "Filtrer les étiquettes",
|
||||||
|
"tag_no_matches": "Aucune étiquette correspondante",
|
||||||
"more_count": "+{count} de plus",
|
"more_count": "+{count} de plus",
|
||||||
"characters_count": "{count} caractères",
|
"characters_count": "{count} caractères",
|
||||||
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID du message",
|
"message_id": "ID du message",
|
||||||
"list_info": "Information de liste"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Répondre",
|
"reply": "Répondre",
|
||||||
"reply_all": "Répondre à tous (a)",
|
"reply_all": "Répondre à tous (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Étiquettes de messagerie",
|
"title": "Étiquettes de messagerie",
|
||||||
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
|
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
|
||||||
"add_keyword": "Ajouter une étiquette",
|
"add_keyword": "Ajouter une étiquette",
|
||||||
"reset_defaults": "Réinitialiser par défaut",
|
|
||||||
"label_field": "Nom d'affichage",
|
"label_field": "Nom d'affichage",
|
||||||
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
||||||
"id_field": "ID d'étiquette",
|
"id_field": "ID d'étiquette",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
||||||
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
|
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Étiquettes imbriquées",
|
||||||
|
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
|
||||||
|
},
|
||||||
|
"parent_field": "Étiquette parente",
|
||||||
|
"no_parent": "Aucune étiquette parente",
|
||||||
|
"too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)",
|
||||||
|
"has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.",
|
||||||
|
"has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci",
|
||||||
|
"visibility_field": "Visibilité dans la barre latérale",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Afficher",
|
||||||
|
"unread": "Afficher si non lus",
|
||||||
|
"hide": "Masquer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Tester le son de notification",
|
"test_sound": "Tester le son de notification",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"mark_as_spam": "Signaler comme spam",
|
"mark_as_spam": "Signaler comme spam",
|
||||||
"not_spam": "Pas un spam",
|
"not_spam": "Pas un spam",
|
||||||
"color_tag": "Étiquette",
|
"tag": "Étiquette",
|
||||||
"remove_color": "Supprimer l'étiquette",
|
|
||||||
"items_selected": "{count} emails sélectionnés",
|
"items_selected": "{count} emails sélectionnés",
|
||||||
"edit_draft": "Modifier le brouillon",
|
"edit_draft": "Modifier le brouillon",
|
||||||
"cancel_scheduled_send": "Annuler l’envoi",
|
"cancel_scheduled_send": "Annuler l’envoi",
|
||||||
|
|||||||
+24
-17
@@ -122,6 +122,8 @@
|
|||||||
"demo_reset": "אִתחוּל",
|
"demo_reset": "אִתחוּל",
|
||||||
"demo_tour": "סִיוּר",
|
"demo_tour": "סִיוּר",
|
||||||
"tags": "תגים",
|
"tags": "תגים",
|
||||||
|
"show_all_tags": "הצג הכל ({count})",
|
||||||
|
"show_fewer_tags": "הצג פחות",
|
||||||
"folders": "תיקיות",
|
"folders": "תיקיות",
|
||||||
"mail": "דוֹאַר",
|
"mail": "דוֹאַר",
|
||||||
"nav_label": "ניווט",
|
"nav_label": "ניווט",
|
||||||
@@ -245,6 +247,7 @@
|
|||||||
"print": "הדפס",
|
"print": "הדפס",
|
||||||
"view_source": "צפה במקור",
|
"view_source": "צפה במקור",
|
||||||
"export_email": "ייצא כ-.eml",
|
"export_email": "ייצא כ-.eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "ייבוא .eml",
|
"import_email": "ייבוא .eml",
|
||||||
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
||||||
"email_source": "מקור דוא\"ל",
|
"email_source": "מקור דוא\"ל",
|
||||||
@@ -271,13 +274,15 @@
|
|||||||
"view_contact": "הצג איש קשר",
|
"view_contact": "הצג איש קשר",
|
||||||
"message_details": "פרטי הודעה",
|
"message_details": "פרטי הודעה",
|
||||||
"more_reply_options": "אפשרויות תשובה נוספות",
|
"more_reply_options": "אפשרויות תשובה נוספות",
|
||||||
"set_color": "הגדר תג",
|
"set_tag": "הגדר תג",
|
||||||
"tag": "תג",
|
"tag": "תג",
|
||||||
"more_actions": "עוד פעולות",
|
"more_actions": "עוד פעולות",
|
||||||
"previous": "הקודם",
|
"previous": "הקודם",
|
||||||
"next": "הבא",
|
"next": "הבא",
|
||||||
"move_to": "העבר ל...",
|
"move_to": "העבר ל...",
|
||||||
"remove_color": "הסר תג",
|
"remove_tag": "הסר תג",
|
||||||
|
"tag_filter_placeholder": "סינון תגים",
|
||||||
|
"tag_no_matches": "אין תגים תואמים",
|
||||||
"more_count": "+{count}נוספים",
|
"more_count": "+{count}נוספים",
|
||||||
"characters_count": "{count} תווים",
|
"characters_count": "{count} תווים",
|
||||||
"quick_reply_placeholder": "תשובה מהירה",
|
"quick_reply_placeholder": "תשובה מהירה",
|
||||||
@@ -346,17 +351,6 @@
|
|||||||
"message_id": "מזהה הודעה",
|
"message_id": "מזהה הודעה",
|
||||||
"list_info": "רשימת מידע"
|
"list_info": "רשימת מידע"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "תג צבע",
|
|
||||||
"red": "אדום",
|
|
||||||
"orange": "כתום",
|
|
||||||
"yellow": "צהוב",
|
|
||||||
"green": "ירוק",
|
|
||||||
"blue": "כחול",
|
|
||||||
"purple": "סגול",
|
|
||||||
"pink": "ורוד",
|
|
||||||
"none": "אין"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "תשובה (ר)",
|
"reply": "תשובה (ר)",
|
||||||
"reply_all": "השב לכולם (א)",
|
"reply_all": "השב לכולם (א)",
|
||||||
@@ -980,7 +974,6 @@
|
|||||||
"title": "מילות מפתח בדוא\"ל",
|
"title": "מילות מפתח בדוא\"ל",
|
||||||
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
|
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
|
||||||
"add_keyword": "הוסף מילת מפתח",
|
"add_keyword": "הוסף מילת מפתח",
|
||||||
"reset_defaults": "אפס לברירות מחדל",
|
|
||||||
"label_field": "שם תצוגה",
|
"label_field": "שם תצוגה",
|
||||||
"label_placeholder": "למשל עבודה, אישי, דחוף",
|
"label_placeholder": "למשל עבודה, אישי, דחוף",
|
||||||
"id_field": "מזהה מילת מפתח",
|
"id_field": "מזהה מילת מפתח",
|
||||||
@@ -993,7 +986,22 @@
|
|||||||
"add": "לְהוֹסִיף",
|
"add": "לְהוֹסִיף",
|
||||||
"cancel": "לְבַטֵל",
|
"cancel": "לְבַטֵל",
|
||||||
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
||||||
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות"
|
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
|
||||||
|
"nesting": {
|
||||||
|
"label": "תגים מקוננים",
|
||||||
|
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
|
||||||
|
},
|
||||||
|
"parent_field": "תג אב",
|
||||||
|
"no_parent": "ללא תג אב",
|
||||||
|
"too_long": "נתיב התג ארוך מדי (עד {max} תווים)",
|
||||||
|
"has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.",
|
||||||
|
"has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה",
|
||||||
|
"visibility_field": "הצגה בסרגל הצד",
|
||||||
|
"visibility": {
|
||||||
|
"show": "הצג",
|
||||||
|
"unread": "הצג כשיש שלא נקראו",
|
||||||
|
"hide": "הסתר"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "צליל הודעת בדיקה",
|
"test_sound": "צליל הודעת בדיקה",
|
||||||
@@ -1999,8 +2007,7 @@
|
|||||||
"delete": "לִמְחוֹק",
|
"delete": "לִמְחוֹק",
|
||||||
"mark_as_spam": "דווח על ספאם",
|
"mark_as_spam": "דווח על ספאם",
|
||||||
"not_spam": "לא ספאם",
|
"not_spam": "לא ספאם",
|
||||||
"color_tag": "תווית",
|
"tag": "תווית",
|
||||||
"remove_color": "הסר תווית",
|
|
||||||
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
||||||
"edit_draft": "ערוך טיוטה",
|
"edit_draft": "ערוך טיוטה",
|
||||||
"cancel_scheduled_send": "ביטול שליחה",
|
"cancel_scheduled_send": "ביטול שליחה",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Visszaállítás",
|
"demo_reset": "Visszaállítás",
|
||||||
"demo_tour": "Bemutató",
|
"demo_tour": "Bemutató",
|
||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
|
"show_all_tags": "Összes megjelenítése ({count})",
|
||||||
|
"show_fewer_tags": "Kevesebb megjelenítése",
|
||||||
"folders": "Mappák",
|
"folders": "Mappák",
|
||||||
"shared": "Megosztott",
|
"shared": "Megosztott",
|
||||||
"mail": "Levelek",
|
"mail": "Levelek",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Nyomtatás",
|
"print": "Nyomtatás",
|
||||||
"view_source": "Forrás megtekintése",
|
"view_source": "Forrás megtekintése",
|
||||||
"export_email": "Exportálás .eml-ként",
|
"export_email": "Exportálás .eml-ként",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importálás .eml vagy .zip fájlból",
|
"import_email": "Importálás .eml vagy .zip fájlból",
|
||||||
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
||||||
"email_source": "E-mail forrás",
|
"email_source": "E-mail forrás",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Névjegy megtekintése",
|
"view_contact": "Névjegy megtekintése",
|
||||||
"message_details": "Üzenet részletei",
|
"message_details": "Üzenet részletei",
|
||||||
"more_reply_options": "További válasz opciók",
|
"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",
|
"tag": "Címke",
|
||||||
"more_actions": "További műveletek",
|
"more_actions": "További műveletek",
|
||||||
"previous": "Előző",
|
"previous": "Előző",
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
"move_to": "Áthelyezés ide...",
|
"move_to": "Áthelyezés ide...",
|
||||||
"remove_color": "Címke eltávolítása",
|
"remove_tag": "Címke eltávolítása",
|
||||||
|
"tag_filter_placeholder": "Címkék szűrése",
|
||||||
|
"tag_no_matches": "Nincs találat a címkék közt",
|
||||||
"more_count": "+{count} további",
|
"more_count": "+{count} további",
|
||||||
"characters_count": "{count} karakter",
|
"characters_count": "{count} karakter",
|
||||||
"quick_reply_placeholder": "Gyors válasz írása...",
|
"quick_reply_placeholder": "Gyors válasz írása...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Üzenet azonosító",
|
"message_id": "Üzenet azonosító",
|
||||||
"list_info": "Lista információk"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Válasz (r)",
|
"reply": "Válasz (r)",
|
||||||
"reply_all": "Válasz mindenkinek (a)",
|
"reply_all": "Válasz mindenkinek (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "E-mail címkék",
|
"title": "E-mail címkék",
|
||||||
"description": "Címkék definiálása az e-mailek színekkel történő rendezéséhez. Ezek JMAP kulcsszavakként tárolódnak a szerveren.",
|
"description": "Címkék definiálása az e-mailek színekkel történő rendezéséhez. Ezek JMAP kulcsszavakként tárolódnak a szerveren.",
|
||||||
"add_keyword": "Címke hozzáadása",
|
"add_keyword": "Címke hozzáadása",
|
||||||
"reset_defaults": "Alapértelmezettek visszaállítása",
|
|
||||||
"label_field": "Megjelenített név",
|
"label_field": "Megjelenített név",
|
||||||
"label_placeholder": "pl. Munka, Személyes, Sürgős",
|
"label_placeholder": "pl. Munka, Személyes, Sürgős",
|
||||||
"id_field": "Címke azonosító",
|
"id_field": "Címke azonosító",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "Hozzáadás",
|
"add": "Hozzáadás",
|
||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
"migrating": "Címke frissítése a meglévő e-maileken...",
|
"migrating": "Címke frissítése a meglévő e-maileken...",
|
||||||
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken"
|
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Beágyazott címkék",
|
||||||
|
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
|
||||||
|
},
|
||||||
|
"parent_field": "Szülőcímke",
|
||||||
|
"no_parent": "Nincs szülőcímke",
|
||||||
|
"too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)",
|
||||||
|
"has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.",
|
||||||
|
"has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket",
|
||||||
|
"visibility_field": "Láthatóság az oldalsávon",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Megjelenítés",
|
||||||
|
"unread": "Megjelenítés olvasatlanoknál",
|
||||||
|
"hide": "Elrejtés"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Értesítési hang tesztelése",
|
"test_sound": "Értesítési hang tesztelése",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"mark_as_spam": "Spam jelentése",
|
"mark_as_spam": "Spam jelentése",
|
||||||
"not_spam": "Nem spam",
|
"not_spam": "Nem spam",
|
||||||
"color_tag": "Címke",
|
"tag": "Címke",
|
||||||
"remove_color": "Címke eltávolítása",
|
|
||||||
"items_selected": "{count} e-mail kijelölve",
|
"items_selected": "{count} e-mail kijelölve",
|
||||||
"edit_draft": "Piszkozat szerkesztése",
|
"edit_draft": "Piszkozat szerkesztése",
|
||||||
"cancel_scheduled_send": "Küldés megszakítása",
|
"cancel_scheduled_send": "Küldés megszakítása",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reimposta",
|
"demo_reset": "Reimposta",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etichette",
|
"tags": "Etichette",
|
||||||
|
"show_all_tags": "Mostra tutto ({count})",
|
||||||
|
"show_fewer_tags": "Mostra meno",
|
||||||
"folders": "Cartelle",
|
"folders": "Cartelle",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Stampa",
|
"print": "Stampa",
|
||||||
"view_source": "Visualizza sorgente",
|
"view_source": "Visualizza sorgente",
|
||||||
"export_email": "Esporta come .eml",
|
"export_email": "Esporta come .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importa .eml o .zip",
|
"import_email": "Importa .eml o .zip",
|
||||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||||
"email_source": "Sorgente del messaggio",
|
"email_source": "Sorgente del messaggio",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Visualizza contatto",
|
"view_contact": "Visualizza contatto",
|
||||||
"message_details": "Dettagli del messaggio",
|
"message_details": "Dettagli del messaggio",
|
||||||
"more_reply_options": "Più opzioni di risposta",
|
"more_reply_options": "Più opzioni di risposta",
|
||||||
"set_color": "Imposta etichetta",
|
"set_tag": "Imposta etichetta",
|
||||||
"tag": "Etichetta",
|
"tag": "Etichetta",
|
||||||
"more_actions": "Altre azioni",
|
"more_actions": "Altre azioni",
|
||||||
"move_to": "Sposta in...",
|
"move_to": "Sposta in...",
|
||||||
"remove_color": "Rimuovi etichetta",
|
"remove_tag": "Rimuovi etichetta",
|
||||||
|
"tag_filter_placeholder": "Filtra etichette",
|
||||||
|
"tag_no_matches": "Nessuna etichetta corrispondente",
|
||||||
"more_count": "+{count} altri",
|
"more_count": "+{count} altri",
|
||||||
"characters_count": "{count} caratteri",
|
"characters_count": "{count} caratteri",
|
||||||
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID messaggio",
|
"message_id": "ID messaggio",
|
||||||
"list_info": "Informazioni lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Rispondi",
|
"reply": "Rispondi",
|
||||||
"reply_all": "Rispondi a tutti (a)",
|
"reply_all": "Rispondi a tutti (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Etichette e-mail",
|
"title": "Etichette e-mail",
|
||||||
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
||||||
"add_keyword": "Aggiungi etichetta",
|
"add_keyword": "Aggiungi etichetta",
|
||||||
"reset_defaults": "Ripristina predefiniti",
|
|
||||||
"label_field": "Nome visualizzato",
|
"label_field": "Nome visualizzato",
|
||||||
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
||||||
"id_field": "ID etichetta",
|
"id_field": "ID etichetta",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Aggiungi",
|
"add": "Aggiungi",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
||||||
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
|
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Etichette nidificate",
|
||||||
|
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
|
||||||
|
},
|
||||||
|
"parent_field": "Etichetta principale",
|
||||||
|
"no_parent": "Nessuna etichetta principale",
|
||||||
|
"too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)",
|
||||||
|
"has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.",
|
||||||
|
"has_children_delete": "Rimuovi prima le etichette nidificate sotto questa",
|
||||||
|
"visibility_field": "Visibilità nella barra laterale",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Mostra",
|
||||||
|
"unread": "Mostra se non letti",
|
||||||
|
"hide": "Nascondi"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Testa il suono di notifica",
|
"test_sound": "Testa il suono di notifica",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"mark_as_spam": "Segnala come spam",
|
"mark_as_spam": "Segnala come spam",
|
||||||
"not_spam": "Non spam",
|
"not_spam": "Non spam",
|
||||||
"color_tag": "Etichetta",
|
"tag": "Etichetta",
|
||||||
"remove_color": "Rimuovi etichetta",
|
|
||||||
"items_selected": "{count} messaggi selezionati",
|
"items_selected": "{count} messaggi selezionati",
|
||||||
"edit_draft": "Modifica bozza",
|
"edit_draft": "Modifica bozza",
|
||||||
"cancel_scheduled_send": "Annulla invio",
|
"cancel_scheduled_send": "Annulla invio",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "リセット",
|
"demo_reset": "リセット",
|
||||||
"demo_tour": "ツアー",
|
"demo_tour": "ツアー",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
|
"show_all_tags": "すべて表示({count})",
|
||||||
|
"show_fewer_tags": "表示を減らす",
|
||||||
"folders": "フォルダ",
|
"folders": "フォルダ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "印刷",
|
"print": "印刷",
|
||||||
"view_source": "ソースを表示",
|
"view_source": "ソースを表示",
|
||||||
"export_email": ".emlとしてエクスポート",
|
"export_email": ".emlとしてエクスポート",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml または .zip をインポート",
|
"import_email": ".eml または .zip をインポート",
|
||||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||||
"email_source": "メールソース",
|
"email_source": "メールソース",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "連絡先を表示",
|
"view_contact": "連絡先を表示",
|
||||||
"message_details": "メッセージの詳細",
|
"message_details": "メッセージの詳細",
|
||||||
"more_reply_options": "その他の返信オプション",
|
"more_reply_options": "その他の返信オプション",
|
||||||
"set_color": "ラベルを設定",
|
"set_tag": "ラベルを設定",
|
||||||
"tag": "ラベル",
|
"tag": "ラベル",
|
||||||
"more_actions": "その他の操作",
|
"more_actions": "その他の操作",
|
||||||
"move_to": "移動...",
|
"move_to": "移動...",
|
||||||
"remove_color": "ラベルを削除",
|
"remove_tag": "ラベルを削除",
|
||||||
|
"tag_filter_placeholder": "ラベルを絞り込む",
|
||||||
|
"tag_no_matches": "一致するラベルがありません",
|
||||||
"more_count": "他{count}件",
|
"more_count": "他{count}件",
|
||||||
"characters_count": "{count}文字",
|
"characters_count": "{count}文字",
|
||||||
"quick_reply_placeholder": "クイック返信を入力...",
|
"quick_reply_placeholder": "クイック返信を入力...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "メッセージID",
|
"message_id": "メッセージID",
|
||||||
"list_info": "リスト情報"
|
"list_info": "リスト情報"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "カラータグ",
|
|
||||||
"red": "赤",
|
|
||||||
"orange": "オレンジ",
|
|
||||||
"yellow": "黄色",
|
|
||||||
"green": "緑",
|
|
||||||
"blue": "青",
|
|
||||||
"purple": "紫",
|
|
||||||
"pink": "ピンク",
|
|
||||||
"none": "なし"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "返信",
|
"reply": "返信",
|
||||||
"reply_all": "全員に返信 (a)",
|
"reply_all": "全員に返信 (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "メールラベル",
|
"title": "メールラベル",
|
||||||
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
||||||
"add_keyword": "ラベルを追加",
|
"add_keyword": "ラベルを追加",
|
||||||
"reset_defaults": "デフォルトに戻す",
|
|
||||||
"label_field": "表示名",
|
"label_field": "表示名",
|
||||||
"label_placeholder": "例:仕事、個人、緊急",
|
"label_placeholder": "例:仕事、個人、緊急",
|
||||||
"id_field": "ラベルID",
|
"id_field": "ラベルID",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "追加",
|
"add": "追加",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"migrating": "既存のメールのラベルを更新中…",
|
"migrating": "既存のメールのラベルを更新中…",
|
||||||
"migration_error": "既存のメールのラベルの更新に失敗しました"
|
"migration_error": "既存のメールのラベルの更新に失敗しました",
|
||||||
|
"nesting": {
|
||||||
|
"label": "ネストされたラベル",
|
||||||
|
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
|
||||||
|
},
|
||||||
|
"parent_field": "親ラベル",
|
||||||
|
"no_parent": "親ラベルなし",
|
||||||
|
"too_long": "このラベルのパスが長すぎます(最大{max}文字)",
|
||||||
|
"has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。",
|
||||||
|
"has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください",
|
||||||
|
"visibility_field": "サイドバーでの表示",
|
||||||
|
"visibility": {
|
||||||
|
"show": "表示する",
|
||||||
|
"unread": "未読がある場合に表示",
|
||||||
|
"hide": "表示しない"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "通知音をテスト",
|
"test_sound": "通知音をテスト",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"mark_as_spam": "迷惑メールを報告",
|
"mark_as_spam": "迷惑メールを報告",
|
||||||
"not_spam": "迷惑メールでない",
|
"not_spam": "迷惑メールでない",
|
||||||
"color_tag": "ラベル",
|
"tag": "ラベル",
|
||||||
"remove_color": "ラベルを削除",
|
|
||||||
"items_selected": "{count}件のメールを選択",
|
"items_selected": "{count}件のメールを選択",
|
||||||
"edit_draft": "下書きを編集",
|
"edit_draft": "下書きを編集",
|
||||||
"cancel_scheduled_send": "送信をキャンセル",
|
"cancel_scheduled_send": "送信をキャンセル",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "초기화",
|
"demo_reset": "초기화",
|
||||||
"demo_tour": "둘러보기",
|
"demo_tour": "둘러보기",
|
||||||
"tags": "태그",
|
"tags": "태그",
|
||||||
|
"show_all_tags": "전체 보기({count})",
|
||||||
|
"show_fewer_tags": "간략히 보기",
|
||||||
"folders": "폴더",
|
"folders": "폴더",
|
||||||
"mail": "메일",
|
"mail": "메일",
|
||||||
"nav_label": "내비게이션",
|
"nav_label": "내비게이션",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "인쇄",
|
"print": "인쇄",
|
||||||
"view_source": "원본 보기",
|
"view_source": "원본 보기",
|
||||||
"export_email": ".eml 파일로 내보내기",
|
"export_email": ".eml 파일로 내보내기",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml 또는 .zip 가져오기",
|
"import_email": ".eml 또는 .zip 가져오기",
|
||||||
"keyboard_shortcuts": "단축키 (?)",
|
"keyboard_shortcuts": "단축키 (?)",
|
||||||
"email_source": "메일 원본",
|
"email_source": "메일 원본",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "연락처 보기",
|
"view_contact": "연락처 보기",
|
||||||
"message_details": "메시지 상세 정보",
|
"message_details": "메시지 상세 정보",
|
||||||
"more_reply_options": "답장 옵션 더보기",
|
"more_reply_options": "답장 옵션 더보기",
|
||||||
"set_color": "태그 설정",
|
"set_tag": "태그 설정",
|
||||||
"tag": "태그",
|
"tag": "태그",
|
||||||
"more_actions": "작업 더보기",
|
"more_actions": "작업 더보기",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"move_to": "이동...",
|
"move_to": "이동...",
|
||||||
"remove_color": "태그 제거",
|
"remove_tag": "태그 제거",
|
||||||
|
"tag_filter_placeholder": "태그 검색",
|
||||||
|
"tag_no_matches": "일치하는 태그 없음",
|
||||||
"more_count": "+{count}개 더보기",
|
"more_count": "+{count}개 더보기",
|
||||||
"characters_count": "{count}자",
|
"characters_count": "{count}자",
|
||||||
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "메시지 ID",
|
"message_id": "메시지 ID",
|
||||||
"list_info": "목록 정보"
|
"list_info": "목록 정보"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "색상 태그",
|
|
||||||
"red": "빨간색",
|
|
||||||
"orange": "주황색",
|
|
||||||
"yellow": "노란색",
|
|
||||||
"green": "초록색",
|
|
||||||
"blue": "파란색",
|
|
||||||
"purple": "보라색",
|
|
||||||
"pink": "분홍색",
|
|
||||||
"none": "없음"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "답장 (r)",
|
"reply": "답장 (r)",
|
||||||
"reply_all": "전체 답장 (a)",
|
"reply_all": "전체 답장 (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "이메일 태그",
|
"title": "이메일 태그",
|
||||||
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
||||||
"add_keyword": "태그 추가",
|
"add_keyword": "태그 추가",
|
||||||
"reset_defaults": "기본값으로 초기화",
|
|
||||||
"label_field": "표시 이름",
|
"label_field": "표시 이름",
|
||||||
"label_placeholder": "예: 업무, 개인, 긴급",
|
"label_placeholder": "예: 업무, 개인, 긴급",
|
||||||
"id_field": "태그 ID",
|
"id_field": "태그 ID",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "추가",
|
"add": "추가",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
"migrating": "기존 이메일의 태그 업데이트 중…",
|
||||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
|
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
|
||||||
|
"nesting": {
|
||||||
|
"label": "중첩 태그",
|
||||||
|
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
|
||||||
|
},
|
||||||
|
"parent_field": "상위 태그",
|
||||||
|
"no_parent": "상위 태그 없음",
|
||||||
|
"too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)",
|
||||||
|
"has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.",
|
||||||
|
"has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요",
|
||||||
|
"visibility_field": "사이드바 표시",
|
||||||
|
"visibility": {
|
||||||
|
"show": "표시",
|
||||||
|
"unread": "읽지 않음이 있을 때 표시",
|
||||||
|
"hide": "숨기기"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "알림음 테스트",
|
"test_sound": "알림음 테스트",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"mark_as_spam": "스팸 신고",
|
"mark_as_spam": "스팸 신고",
|
||||||
"not_spam": "정상 메일",
|
"not_spam": "정상 메일",
|
||||||
"color_tag": "태그",
|
"tag": "태그",
|
||||||
"remove_color": "태그 제거",
|
|
||||||
"items_selected": "{count}개의 메일 선택됨",
|
"items_selected": "{count}개의 메일 선택됨",
|
||||||
"edit_draft": "임시보관 메일 수정",
|
"edit_draft": "임시보관 메일 수정",
|
||||||
"cancel_scheduled_send": "보내기 취소",
|
"cancel_scheduled_send": "보내기 취소",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Atiestatīt",
|
"demo_reset": "Atiestatīt",
|
||||||
"demo_tour": "Ekskursija",
|
"demo_tour": "Ekskursija",
|
||||||
"tags": "Tagi",
|
"tags": "Tagi",
|
||||||
|
"show_all_tags": "Rādīt visus ({count})",
|
||||||
|
"show_fewer_tags": "Rādīt mazāk",
|
||||||
"folders": "Mapes",
|
"folders": "Mapes",
|
||||||
"mail": "Pasts",
|
"mail": "Pasts",
|
||||||
"nav_label": "Navigācija",
|
"nav_label": "Navigācija",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drukāt",
|
"print": "Drukāt",
|
||||||
"view_source": "Skatīt avota kodu",
|
"view_source": "Skatīt avota kodu",
|
||||||
"export_email": "Eksportēt kā .eml",
|
"export_email": "Eksportēt kā .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importēt .eml vai .zip",
|
"import_email": "Importēt .eml vai .zip",
|
||||||
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
||||||
"email_source": "Vēstules avota kods",
|
"email_source": "Vēstules avota kods",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Skatīt kontaktu",
|
"view_contact": "Skatīt kontaktu",
|
||||||
"message_details": "Informācija par ziņojumu",
|
"message_details": "Informācija par ziņojumu",
|
||||||
"more_reply_options": "Papildu atbildēšanas iespējas",
|
"more_reply_options": "Papildu atbildēšanas iespējas",
|
||||||
"set_color": "Iestatīt tagu",
|
"set_tag": "Iestatīt tagu",
|
||||||
"tag": "Tags",
|
"tag": "Tags",
|
||||||
"more_actions": "Citas darbības",
|
"more_actions": "Citas darbības",
|
||||||
"previous": "Iepr.",
|
"previous": "Iepr.",
|
||||||
"next": "Nāk.",
|
"next": "Nāk.",
|
||||||
"move_to": "Pārvietot uz...",
|
"move_to": "Pārvietot uz...",
|
||||||
"remove_color": "Noņemt tagu",
|
"remove_tag": "Noņemt tagu",
|
||||||
|
"tag_filter_placeholder": "Filtrēt tagus",
|
||||||
|
"tag_no_matches": "Nav atbilstošu tagu",
|
||||||
"more_count": "+vairāk {count}",
|
"more_count": "+vairāk {count}",
|
||||||
"characters_count": "{count} rakstzīmes",
|
"characters_count": "{count} rakstzīmes",
|
||||||
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "Ziņojuma ID",
|
"message_id": "Ziņojuma ID",
|
||||||
"list_info": "Informācija par adresātu sarakstu"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Atbildēt (r)",
|
"reply": "Atbildēt (r)",
|
||||||
"reply_all": "Atbildēt visiem (a)",
|
"reply_all": "Atbildēt visiem (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "E-pasta tagi",
|
"title": "E-pasta tagi",
|
||||||
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
|
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
|
||||||
"add_keyword": "Pievienot tagu",
|
"add_keyword": "Pievienot tagu",
|
||||||
"reset_defaults": "Atiestatīt noklusējumu",
|
|
||||||
"label_field": "Redzamais nosaukums",
|
"label_field": "Redzamais nosaukums",
|
||||||
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
||||||
"id_field": "Taga identifikators",
|
"id_field": "Taga identifikators",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Pievienot",
|
"add": "Pievienot",
|
||||||
"cancel": "Atcelt",
|
"cancel": "Atcelt",
|
||||||
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
||||||
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
|
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Ligzdoti tagi",
|
||||||
|
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
|
||||||
|
},
|
||||||
|
"parent_field": "Vecāktags",
|
||||||
|
"no_parent": "Nav vecāktaga",
|
||||||
|
"too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)",
|
||||||
|
"has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.",
|
||||||
|
"has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus",
|
||||||
|
"visibility_field": "Redzamība sānjoslā",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Rādīt",
|
||||||
|
"unread": "Rādīt, ja ir nelasīti",
|
||||||
|
"hide": "Slēpt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Dzēst",
|
"delete": "Dzēst",
|
||||||
"mark_as_spam": "Atzīmēt kā mēstuli",
|
"mark_as_spam": "Atzīmēt kā mēstuli",
|
||||||
"not_spam": "Nav mēstule",
|
"not_spam": "Nav mēstule",
|
||||||
"color_tag": "Tags",
|
"tag": "Tags",
|
||||||
"remove_color": "Noņemt tagu",
|
|
||||||
"items_selected": "{count} vēstules atlasītas",
|
"items_selected": "{count} vēstules atlasītas",
|
||||||
"edit_draft": "Rediģēt melnrakstu",
|
"edit_draft": "Rediģēt melnrakstu",
|
||||||
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||||
|
|||||||
+25
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetten",
|
"demo_reset": "Resetten",
|
||||||
"demo_tour": "Rondleiding",
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
|
"show_all_tags": "Alles tonen ({count})",
|
||||||
|
"show_fewer_tags": "Minder tonen",
|
||||||
"folders": "Mappen",
|
"folders": "Mappen",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Afdrukken",
|
"print": "Afdrukken",
|
||||||
"view_source": "Bron bekijken",
|
"view_source": "Bron bekijken",
|
||||||
"export_email": "Exporteren als .eml",
|
"export_email": "Exporteren als .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml of .zip importeren",
|
"import_email": ".eml of .zip importeren",
|
||||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||||
"email_source": "E-mailbron",
|
"email_source": "E-mailbron",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Contact bekijken",
|
"view_contact": "Contact bekijken",
|
||||||
"message_details": "Berichtdetails",
|
"message_details": "Berichtdetails",
|
||||||
"more_reply_options": "Meer antwoordopties",
|
"more_reply_options": "Meer antwoordopties",
|
||||||
"set_color": "Label instellen",
|
"set_tag": "Label instellen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Meer acties",
|
"more_actions": "Meer acties",
|
||||||
"move_to": "Verplaatsen naar...",
|
"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",
|
"more_count": "+{count} meer",
|
||||||
"characters_count": "{count} tekens",
|
"characters_count": "{count} tekens",
|
||||||
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "Bericht-ID",
|
"message_id": "Bericht-ID",
|
||||||
"list_info": "Lijstinformatie"
|
"list_info": "Lijstinformatie"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Kleurtag",
|
|
||||||
"red": "Rood",
|
|
||||||
"orange": "Oranje",
|
|
||||||
"yellow": "Geel",
|
|
||||||
"green": "Groen",
|
|
||||||
"blue": "Blauw",
|
|
||||||
"purple": "Paars",
|
|
||||||
"pink": "Roze",
|
|
||||||
"none": "Geen"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Beantwoorden",
|
"reply": "Beantwoorden",
|
||||||
"reply_all": "Allen beantwoorden (a)",
|
"reply_all": "Allen beantwoorden (a)",
|
||||||
@@ -1013,9 +1007,8 @@
|
|||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"title": "E-maillabels",
|
"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",
|
"add_keyword": "Label toevoegen",
|
||||||
"reset_defaults": "Standaardwaarden herstellen",
|
|
||||||
"label_field": "Weergavenaam",
|
"label_field": "Weergavenaam",
|
||||||
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||||
"id_field": "Label-ID",
|
"id_field": "Label-ID",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Toevoegen",
|
"add": "Toevoegen",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
"migrating": "Label bijwerken op bestaande e-mails…",
|
||||||
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
|
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Geneste labels",
|
||||||
|
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
|
||||||
|
},
|
||||||
|
"parent_field": "Bovenliggend label",
|
||||||
|
"no_parent": "Geen bovenliggend label",
|
||||||
|
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
|
||||||
|
"has_children_locked": "Er vallen andere labels onder dit label, dus de naam en het bovenliggende label liggen vast. Verplaats of verwijder ze eerst.",
|
||||||
|
"has_children_delete": "Verwijder eerst de labels die hieronder vallen",
|
||||||
|
"visibility_field": "Zichtbaarheid in de zijbalk",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Tonen",
|
||||||
|
"unread": "Tonen bij ongelezen",
|
||||||
|
"hide": "Verbergen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Meldingsgeluid testen",
|
"test_sound": "Meldingsgeluid testen",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"mark_as_spam": "Spam melden",
|
"mark_as_spam": "Spam melden",
|
||||||
"not_spam": "Geen spam",
|
"not_spam": "Geen spam",
|
||||||
"color_tag": "Label",
|
"tag": "Label",
|
||||||
"remove_color": "Label verwijderen",
|
|
||||||
"items_selected": "{count} e-mails geselecteerd",
|
"items_selected": "{count} e-mails geselecteerd",
|
||||||
"edit_draft": "Concept bewerken",
|
"edit_draft": "Concept bewerken",
|
||||||
"cancel_scheduled_send": "Verzenden annuleren",
|
"cancel_scheduled_send": "Verzenden annuleren",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetuj",
|
"demo_reset": "Resetuj",
|
||||||
"demo_tour": "Przewodnik",
|
"demo_tour": "Przewodnik",
|
||||||
"tags": "Etykiety",
|
"tags": "Etykiety",
|
||||||
|
"show_all_tags": "Pokaż wszystkie ({count})",
|
||||||
|
"show_fewer_tags": "Pokaż mniej",
|
||||||
"folders": "Foldery",
|
"folders": "Foldery",
|
||||||
"mail": "Poczta",
|
"mail": "Poczta",
|
||||||
"nav_label": "Nawigacja",
|
"nav_label": "Nawigacja",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drukuj",
|
"print": "Drukuj",
|
||||||
"view_source": "Pokaż źródło",
|
"view_source": "Pokaż źródło",
|
||||||
"export_email": "Eksportuj jako .eml",
|
"export_email": "Eksportuj jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importuj .eml lub .zip",
|
"import_email": "Importuj .eml lub .zip",
|
||||||
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
||||||
"email_source": "Źródło wiadomości",
|
"email_source": "Źródło wiadomości",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Pokaż kontakt",
|
"view_contact": "Pokaż kontakt",
|
||||||
"message_details": "Szczegóły wiadomości",
|
"message_details": "Szczegóły wiadomości",
|
||||||
"more_reply_options": "Więcej opcji odpowiedzi",
|
"more_reply_options": "Więcej opcji odpowiedzi",
|
||||||
"set_color": "Ustaw etykietę",
|
"set_tag": "Ustaw etykietę",
|
||||||
"tag": "Etykieta",
|
"tag": "Etykieta",
|
||||||
"more_actions": "Więcej działań",
|
"more_actions": "Więcej działań",
|
||||||
"previous": "Poprz.",
|
"previous": "Poprz.",
|
||||||
"next": "Nast.",
|
"next": "Nast.",
|
||||||
"move_to": "Przenieś do...",
|
"move_to": "Przenieś do...",
|
||||||
"remove_color": "Usuń etykietę",
|
"remove_tag": "Usuń etykietę",
|
||||||
|
"tag_filter_placeholder": "Filtruj etykiety",
|
||||||
|
"tag_no_matches": "Brak pasujących etykiet",
|
||||||
"more_count": "+{count} więcej",
|
"more_count": "+{count} więcej",
|
||||||
"characters_count": "{count} znaków",
|
"characters_count": "{count} znaków",
|
||||||
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID wiadomości",
|
"message_id": "ID wiadomości",
|
||||||
"list_info": "Informacje o liście"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpowiedz (r)",
|
"reply": "Odpowiedz (r)",
|
||||||
"reply_all": "Odpowiedz wszystkim (a)",
|
"reply_all": "Odpowiedz wszystkim (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Etykiety e-mail",
|
"title": "Etykiety e-mail",
|
||||||
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
|
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
|
||||||
"add_keyword": "Dodaj etykietę",
|
"add_keyword": "Dodaj etykietę",
|
||||||
"reset_defaults": "Przywróć domyślne",
|
|
||||||
"label_field": "Nazwa wyświetlana",
|
"label_field": "Nazwa wyświetlana",
|
||||||
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
||||||
"id_field": "ID etykiety",
|
"id_field": "ID etykiety",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Dodaj",
|
"add": "Dodaj",
|
||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
||||||
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
|
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Zagnieżdżone etykiety",
|
||||||
|
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
|
||||||
|
},
|
||||||
|
"parent_field": "Etykieta nadrzędna",
|
||||||
|
"no_parent": "Brak etykiety nadrzędnej",
|
||||||
|
"too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)",
|
||||||
|
"has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.",
|
||||||
|
"has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą",
|
||||||
|
"visibility_field": "Widoczność w panelu bocznym",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Pokaż",
|
||||||
|
"unread": "Pokaż przy nieprzeczytanych",
|
||||||
|
"hide": "Ukryj"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Przetestuj dźwięk powiadomienia",
|
"test_sound": "Przetestuj dźwięk powiadomienia",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"mark_as_spam": "Zgłoś spam",
|
"mark_as_spam": "Zgłoś spam",
|
||||||
"not_spam": "To nie spam",
|
"not_spam": "To nie spam",
|
||||||
"color_tag": "Etykieta",
|
"tag": "Etykieta",
|
||||||
"remove_color": "Usuń etykietę",
|
|
||||||
"items_selected": "{count} zaznaczonych wiadomości",
|
"items_selected": "{count} zaznaczonych wiadomości",
|
||||||
"edit_draft": "Edytuj szkic",
|
"edit_draft": "Edytuj szkic",
|
||||||
"cancel_scheduled_send": "Anuluj wysyłkę",
|
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Repor",
|
"demo_reset": "Repor",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"show_all_tags": "Mostrar tudo ({count})",
|
||||||
|
"show_fewer_tags": "Mostrar menos",
|
||||||
"folders": "Pastas",
|
"folders": "Pastas",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código-fonte",
|
"view_source": "Ver código-fonte",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml ou .zip",
|
"import_email": "Importar .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||||
"email_source": "Código-fonte do E-mail",
|
"email_source": "Código-fonte do E-mail",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Ver contato",
|
"view_contact": "Ver contato",
|
||||||
"message_details": "Detalhes da Mensagem",
|
"message_details": "Detalhes da Mensagem",
|
||||||
"more_reply_options": "Mais opções de resposta",
|
"more_reply_options": "Mais opções de resposta",
|
||||||
"set_color": "Definir etiqueta",
|
"set_tag": "Definir etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Mais ações",
|
"more_actions": "Mais ações",
|
||||||
"move_to": "Mover para...",
|
"move_to": "Mover para...",
|
||||||
"remove_color": "Remover etiqueta",
|
"remove_tag": "Remover etiqueta",
|
||||||
|
"tag_filter_placeholder": "Filtrar etiquetas",
|
||||||
|
"tag_no_matches": "Nenhuma etiqueta correspondente",
|
||||||
"more_count": "+{count} mais",
|
"more_count": "+{count} mais",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID da Mensagem",
|
"message_id": "ID da Mensagem",
|
||||||
"list_info": "Informações da Lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
"reply_all": "Responder a todos (a)",
|
"reply_all": "Responder a todos (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Etiquetas de e-mail",
|
"title": "Etiquetas de e-mail",
|
||||||
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
|
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
|
||||||
"add_keyword": "Adicionar etiqueta",
|
"add_keyword": "Adicionar etiqueta",
|
||||||
"reset_defaults": "Restaurar padrões",
|
|
||||||
"label_field": "Nome de exibição",
|
"label_field": "Nome de exibição",
|
||||||
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
||||||
"id_field": "ID da etiqueta",
|
"id_field": "ID da etiqueta",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
||||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
|
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Etiquetas aninhadas",
|
||||||
|
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
|
||||||
|
},
|
||||||
|
"parent_field": "Etiqueta principal",
|
||||||
|
"no_parent": "Sem etiqueta principal",
|
||||||
|
"too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)",
|
||||||
|
"has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.",
|
||||||
|
"has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta",
|
||||||
|
"visibility_field": "Visibilidade na barra lateral",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Mostrar",
|
||||||
|
"unread": "Mostrar se não lidas",
|
||||||
|
"hide": "Ocultar"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Testar som de notificação",
|
"test_sound": "Testar som de notificação",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"mark_as_spam": "Reportar spam",
|
"mark_as_spam": "Reportar spam",
|
||||||
"not_spam": "Não é spam",
|
"not_spam": "Não é spam",
|
||||||
"color_tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"remove_color": "Remover etiqueta",
|
|
||||||
"items_selected": "{count} e-mails selecionados",
|
"items_selected": "{count} e-mails selecionados",
|
||||||
"edit_draft": "Editar rascunho",
|
"edit_draft": "Editar rascunho",
|
||||||
"cancel_scheduled_send": "Cancelar envio",
|
"cancel_scheduled_send": "Cancelar envio",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetare",
|
"demo_reset": "Resetare",
|
||||||
"demo_tour": "Tur de prezentare",
|
"demo_tour": "Tur de prezentare",
|
||||||
"tags": "Etichete",
|
"tags": "Etichete",
|
||||||
|
"show_all_tags": "Afișează tot ({count})",
|
||||||
|
"show_fewer_tags": "Afișează mai puține",
|
||||||
"folders": "Dosare",
|
"folders": "Dosare",
|
||||||
"shared": "Partajat",
|
"shared": "Partajat",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimare",
|
"print": "Imprimare",
|
||||||
"view_source": "Vizualizați sursa",
|
"view_source": "Vizualizați sursa",
|
||||||
"export_email": "Exportați ca fișier .eml",
|
"export_email": "Exportați ca fișier .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importați fișiere .eml sau .zip",
|
"import_email": "Importați fișiere .eml sau .zip",
|
||||||
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
||||||
"email_source": "Sursa e-mailului",
|
"email_source": "Sursa e-mailului",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Vizualizare contact",
|
"view_contact": "Vizualizare contact",
|
||||||
"message_details": "Detalii mesaj",
|
"message_details": "Detalii mesaj",
|
||||||
"more_reply_options": "Mai multe opțiuni de răspuns",
|
"more_reply_options": "Mai multe opțiuni de răspuns",
|
||||||
"set_color": "Setați eticheta",
|
"set_tag": "Setați eticheta",
|
||||||
"tag": "Etichetă",
|
"tag": "Etichetă",
|
||||||
"more_actions": "Alte acțiuni",
|
"more_actions": "Alte acțiuni",
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Următorul",
|
"next": "Următorul",
|
||||||
"move_to": "Mergi la...",
|
"move_to": "Mergi la...",
|
||||||
"remove_color": "Eliminați eticheta",
|
"remove_tag": "Eliminați eticheta",
|
||||||
|
"tag_filter_placeholder": "Filtrează etichetele",
|
||||||
|
"tag_no_matches": "Nicio etichetă corespunzătoare",
|
||||||
"more_count": "+{count} mai multe",
|
"more_count": "+{count} mai multe",
|
||||||
"characters_count": "{count} caractere",
|
"characters_count": "{count} caractere",
|
||||||
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "IDul mesajelor",
|
"message_id": "IDul mesajelor",
|
||||||
"list_info": "Informații despre listă"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Răspunde (r)",
|
"reply": "Răspunde (r)",
|
||||||
"reply_all": "Răspunde tuturor (a)",
|
"reply_all": "Răspunde tuturor (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "Etichete de e-mail",
|
"title": "Etichete de e-mail",
|
||||||
"description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.",
|
"description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.",
|
||||||
"add_keyword": "Adăugați etichetă",
|
"add_keyword": "Adăugați etichetă",
|
||||||
"reset_defaults": "Resezare la setările implicite",
|
|
||||||
"label_field": "Nume afișat",
|
"label_field": "Nume afișat",
|
||||||
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
|
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
|
||||||
"id_field": "EtichetăID",
|
"id_field": "EtichetăID",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "Adăugați",
|
"add": "Adăugați",
|
||||||
"cancel": "Anulează",
|
"cancel": "Anulează",
|
||||||
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
|
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
|
||||||
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente"
|
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Etichete imbricate",
|
||||||
|
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
|
||||||
|
},
|
||||||
|
"parent_field": "Etichetă părinte",
|
||||||
|
"no_parent": "Fără etichetă părinte",
|
||||||
|
"too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)",
|
||||||
|
"has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.",
|
||||||
|
"has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta",
|
||||||
|
"visibility_field": "Vizibilitate în bara laterală",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Afișează",
|
||||||
|
"unread": "Afișează dacă sunt necitite",
|
||||||
|
"hide": "Ascunde"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Testați sunetul de notificare",
|
"test_sound": "Testați sunetul de notificare",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Șterge",
|
"delete": "Șterge",
|
||||||
"mark_as_spam": "Raportează spamul",
|
"mark_as_spam": "Raportează spamul",
|
||||||
"not_spam": "Nu este spam",
|
"not_spam": "Nu este spam",
|
||||||
"color_tag": "Etichetă",
|
"tag": "Etichetă",
|
||||||
"remove_color": "Eliminați eticheta",
|
|
||||||
"items_selected": "{count} e-mailuri selectate",
|
"items_selected": "{count} e-mailuri selectate",
|
||||||
"edit_draft": "Editează schița",
|
"edit_draft": "Editează schița",
|
||||||
"cancel_scheduled_send": "Anulează trimiterea",
|
"cancel_scheduled_send": "Anulează trimiterea",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Сбросить",
|
"demo_reset": "Сбросить",
|
||||||
"demo_tour": "Тур",
|
"demo_tour": "Тур",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
|
"show_all_tags": "Показать все ({count})",
|
||||||
|
"show_fewer_tags": "Показать меньше",
|
||||||
"folders": "Папки",
|
"folders": "Папки",
|
||||||
"mail": "Почта",
|
"mail": "Почта",
|
||||||
"nav_label": "Навигация",
|
"nav_label": "Навигация",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Распечатать",
|
"print": "Распечатать",
|
||||||
"view_source": "Просмотреть исходный код",
|
"view_source": "Просмотреть исходный код",
|
||||||
"export_email": "Экспортировать как .eml",
|
"export_email": "Экспортировать как .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Импортировать .eml или .zip",
|
"import_email": "Импортировать .eml или .zip",
|
||||||
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
||||||
"email_source": "Исходный код письма",
|
"email_source": "Исходный код письма",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Просмотреть контакт",
|
"view_contact": "Просмотреть контакт",
|
||||||
"message_details": "Детали сообщения",
|
"message_details": "Детали сообщения",
|
||||||
"more_reply_options": "Дополнительные параметры ответа",
|
"more_reply_options": "Дополнительные параметры ответа",
|
||||||
"set_color": "Установить тег",
|
"set_tag": "Установить тег",
|
||||||
"tag": "Тег",
|
"tag": "Тег",
|
||||||
"more_actions": "Другие действия",
|
"more_actions": "Другие действия",
|
||||||
"previous": "Пред.",
|
"previous": "Пред.",
|
||||||
"next": "След.",
|
"next": "След.",
|
||||||
"move_to": "Переместить в...",
|
"move_to": "Переместить в...",
|
||||||
"remove_color": "Удалить тег",
|
"remove_tag": "Удалить тег",
|
||||||
|
"tag_filter_placeholder": "Фильтр тегов",
|
||||||
|
"tag_no_matches": "Подходящих тегов нет",
|
||||||
"more_count": "+{count} ещё",
|
"more_count": "+{count} ещё",
|
||||||
"characters_count": "{count} символов",
|
"characters_count": "{count} символов",
|
||||||
"quick_reply_placeholder": "Написать быстрый ответ...",
|
"quick_reply_placeholder": "Написать быстрый ответ...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "Идентификатор сообщения",
|
"message_id": "Идентификатор сообщения",
|
||||||
"list_info": "Информация о рассылке"
|
"list_info": "Информация о рассылке"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Цветной тег",
|
|
||||||
"red": "Красный",
|
|
||||||
"orange": "Оранжевый",
|
|
||||||
"yellow": "Жёлтый",
|
|
||||||
"green": "Зелёный",
|
|
||||||
"blue": "Синий",
|
|
||||||
"purple": "Фиолетовый",
|
|
||||||
"pink": "Розовый",
|
|
||||||
"none": "Нет"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Ответить (r)",
|
"reply": "Ответить (r)",
|
||||||
"reply_all": "Ответить всем (a)",
|
"reply_all": "Ответить всем (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Теги электронной почты",
|
"title": "Теги электронной почты",
|
||||||
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
||||||
"add_keyword": "Добавить тег",
|
"add_keyword": "Добавить тег",
|
||||||
"reset_defaults": "Сбросить по умолчанию",
|
|
||||||
"label_field": "Отображаемое название",
|
"label_field": "Отображаемое название",
|
||||||
"label_placeholder": "напр., Работа, Личное, Срочно",
|
"label_placeholder": "напр., Работа, Личное, Срочно",
|
||||||
"id_field": "Идентификатор тега",
|
"id_field": "Идентификатор тега",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"migrating": "Обновление тега в существующих письмах…",
|
"migrating": "Обновление тега в существующих письмах…",
|
||||||
"migration_error": "Не удалось обновить тег в существующих письмах"
|
"migration_error": "Не удалось обновить тег в существующих письмах",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Вложенные теги",
|
||||||
|
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
|
||||||
|
},
|
||||||
|
"parent_field": "Родительский тег",
|
||||||
|
"no_parent": "Без родительского тега",
|
||||||
|
"too_long": "Этот путь тега слишком длинный (не более {max} символов)",
|
||||||
|
"has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.",
|
||||||
|
"has_children_delete": "Сначала удалите теги, вложенные в этот",
|
||||||
|
"visibility_field": "Видимость в боковой панели",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Показывать",
|
||||||
|
"unread": "Показывать при непрочитанных",
|
||||||
|
"hide": "Скрывать"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Проверить звук уведомления",
|
"test_sound": "Проверить звук уведомления",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"mark_as_spam": "Отметить как спам",
|
"mark_as_spam": "Отметить как спам",
|
||||||
"not_spam": "Не спам",
|
"not_spam": "Не спам",
|
||||||
"color_tag": "Тег",
|
"tag": "Тег",
|
||||||
"remove_color": "Удалить тег",
|
|
||||||
"items_selected": "{count} писем выбрано",
|
"items_selected": "{count} писем выбрано",
|
||||||
"edit_draft": "Редактировать черновик",
|
"edit_draft": "Редактировать черновик",
|
||||||
"cancel_scheduled_send": "Отменить отправку",
|
"cancel_scheduled_send": "Отменить отправку",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetovať",
|
"demo_reset": "Resetovať",
|
||||||
"demo_tour": "Sprievodca",
|
"demo_tour": "Sprievodca",
|
||||||
"tags": "Štítky",
|
"tags": "Štítky",
|
||||||
|
"show_all_tags": "Zobraziť všetko ({count})",
|
||||||
|
"show_fewer_tags": "Zobraziť menej",
|
||||||
"folders": "Priečinky",
|
"folders": "Priečinky",
|
||||||
"shared": "Zdieľané",
|
"shared": "Zdieľané",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Tlačiť",
|
"print": "Tlačiť",
|
||||||
"view_source": "Zobraziť zdrojový kód",
|
"view_source": "Zobraziť zdrojový kód",
|
||||||
"export_email": "Exportovať ako .eml",
|
"export_email": "Exportovať ako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovať .eml alebo .zip",
|
"import_email": "Importovať .eml alebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové skratky (?)",
|
"keyboard_shortcuts": "Klávesové skratky (?)",
|
||||||
"email_source": "Zdrojový kód e-mailu",
|
"email_source": "Zdrojový kód e-mailu",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Zobraziť kontakt",
|
"view_contact": "Zobraziť kontakt",
|
||||||
"message_details": "Podrobnosti správy",
|
"message_details": "Podrobnosti správy",
|
||||||
"more_reply_options": "Viac možností odpovede",
|
"more_reply_options": "Viac možností odpovede",
|
||||||
"set_color": "Nastaviť štítok",
|
"set_tag": "Nastaviť štítok",
|
||||||
"tag": "Štítok",
|
"tag": "Štítok",
|
||||||
"more_actions": "Viac akcií",
|
"more_actions": "Viac akcií",
|
||||||
"previous": "Predchádzajúci",
|
"previous": "Predchádzajúci",
|
||||||
"next": "Ďalší",
|
"next": "Ďalší",
|
||||||
"move_to": "Presunúť do...",
|
"move_to": "Presunúť do...",
|
||||||
"remove_color": "Odstrániť štítok",
|
"remove_tag": "Odstrániť štítok",
|
||||||
|
"tag_filter_placeholder": "Filtrovať štítky",
|
||||||
|
"tag_no_matches": "Žiadne zodpovedajúce štítky",
|
||||||
"more_count": "+{count} ďalších",
|
"more_count": "+{count} ďalších",
|
||||||
"characters_count": "{count} znakov",
|
"characters_count": "{count} znakov",
|
||||||
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "ID správy",
|
"message_id": "ID správy",
|
||||||
"list_info": "Informácie o zozname"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpovedať (r)",
|
"reply": "Odpovedať (r)",
|
||||||
"reply_all": "Odpovedať všetkým (a)",
|
"reply_all": "Odpovedať všetkým (a)",
|
||||||
@@ -1018,7 +1012,6 @@
|
|||||||
"title": "E-mailové štítky",
|
"title": "E-mailové štítky",
|
||||||
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
|
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
|
||||||
"add_keyword": "Pridať štítok",
|
"add_keyword": "Pridať štítok",
|
||||||
"reset_defaults": "Obnoviť predvolené",
|
|
||||||
"label_field": "Zobrazovaný názov",
|
"label_field": "Zobrazovaný názov",
|
||||||
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
|
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
|
||||||
"id_field": "ID štítku",
|
"id_field": "ID štítku",
|
||||||
@@ -1031,7 +1024,22 @@
|
|||||||
"add": "Pridať",
|
"add": "Pridať",
|
||||||
"cancel": "Zrušiť",
|
"cancel": "Zrušiť",
|
||||||
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
|
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
|
||||||
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch"
|
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Vnorené štítky",
|
||||||
|
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
|
||||||
|
},
|
||||||
|
"parent_field": "Nadradený štítok",
|
||||||
|
"no_parent": "Bez nadradeného štítku",
|
||||||
|
"too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)",
|
||||||
|
"has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.",
|
||||||
|
"has_children_delete": "Najprv odstráňte štítky vnorené pod týmto",
|
||||||
|
"visibility_field": "Viditeľnosť v bočnom paneli",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Zobraziť",
|
||||||
|
"unread": "Zobraziť pri neprečítaných",
|
||||||
|
"hide": "Skryť"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Otestovať zvuk oznámenia",
|
"test_sound": "Otestovať zvuk oznámenia",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Odstrániť",
|
"delete": "Odstrániť",
|
||||||
"mark_as_spam": "Nahlásiť spam",
|
"mark_as_spam": "Nahlásiť spam",
|
||||||
"not_spam": "Nie je spam",
|
"not_spam": "Nie je spam",
|
||||||
"color_tag": "Štítok",
|
"tag": "Štítok",
|
||||||
"remove_color": "Odstrániť štítok",
|
|
||||||
"items_selected": "{count} vybraných e-mailov",
|
"items_selected": "{count} vybraných e-mailov",
|
||||||
"edit_draft": "Upraviť koncept",
|
"edit_draft": "Upraviť koncept",
|
||||||
"cancel_scheduled_send": "Zrušiť odoslanie",
|
"cancel_scheduled_send": "Zrušiť odoslanie",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Sıfırla",
|
"demo_reset": "Sıfırla",
|
||||||
"demo_tour": "Tur",
|
"demo_tour": "Tur",
|
||||||
"tags": "Etiketler",
|
"tags": "Etiketler",
|
||||||
|
"show_all_tags": "Tümünü göster ({count})",
|
||||||
|
"show_fewer_tags": "Daha az göster",
|
||||||
"folders": "Klasörler",
|
"folders": "Klasörler",
|
||||||
"shared": "Paylaşılan",
|
"shared": "Paylaşılan",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Yazdır",
|
"print": "Yazdır",
|
||||||
"view_source": "Kaynağı görüntüle",
|
"view_source": "Kaynağı görüntüle",
|
||||||
"export_email": ".eml olarak dışa aktar",
|
"export_email": ".eml olarak dışa aktar",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml veya .zip içe aktar",
|
"import_email": ".eml veya .zip içe aktar",
|
||||||
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
||||||
"email_source": "E-posta Kaynağı",
|
"email_source": "E-posta Kaynağı",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Kişiyi görüntüle",
|
"view_contact": "Kişiyi görüntüle",
|
||||||
"message_details": "İleti Ayrıntıları",
|
"message_details": "İleti Ayrıntıları",
|
||||||
"more_reply_options": "Daha fazla yanıt seçeneği",
|
"more_reply_options": "Daha fazla yanıt seçeneği",
|
||||||
"set_color": "Etiket ayarla",
|
"set_tag": "Etiket ayarla",
|
||||||
"tag": "Etiket",
|
"tag": "Etiket",
|
||||||
"more_actions": "Diğer işlemler",
|
"more_actions": "Diğer işlemler",
|
||||||
"previous": "Önceki",
|
"previous": "Önceki",
|
||||||
"next": "Sonraki",
|
"next": "Sonraki",
|
||||||
"move_to": "Şuraya taşı...",
|
"move_to": "Şuraya taşı...",
|
||||||
"remove_color": "Etiketi kaldır",
|
"remove_tag": "Etiketi kaldır",
|
||||||
|
"tag_filter_placeholder": "Etiketleri filtrele",
|
||||||
|
"tag_no_matches": "Eşleşen etiket yok",
|
||||||
"more_count": "+{count} daha",
|
"more_count": "+{count} daha",
|
||||||
"characters_count": "{count} karakter",
|
"characters_count": "{count} karakter",
|
||||||
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "İleti Kimliği",
|
"message_id": "İleti Kimliği",
|
||||||
"list_info": "Liste Bilgisi"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Yanıtla (r)",
|
"reply": "Yanıtla (r)",
|
||||||
"reply_all": "Tümünü Yanıtla (a)",
|
"reply_all": "Tümünü Yanıtla (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "E-posta Etiketleri",
|
"title": "E-posta Etiketleri",
|
||||||
"description": "E-postalarınızı renklerle düzenlemek için etiketler tanımlayın. Bunlar sunucuda JMAP anahtar sözcükleri olarak saklanır.",
|
"description": "E-postalarınızı renklerle düzenlemek için etiketler tanımlayın. Bunlar sunucuda JMAP anahtar sözcükleri olarak saklanır.",
|
||||||
"add_keyword": "Etiket Ekle",
|
"add_keyword": "Etiket Ekle",
|
||||||
"reset_defaults": "Varsayılanlara Sıfırla",
|
|
||||||
"label_field": "Görünen Ad",
|
"label_field": "Görünen Ad",
|
||||||
"label_placeholder": "ör. İş, Kişisel, Acil",
|
"label_placeholder": "ör. İş, Kişisel, Acil",
|
||||||
"id_field": "Etiket Kimliği",
|
"id_field": "Etiket Kimliği",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "Ekle",
|
"add": "Ekle",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
|
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
|
||||||
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi"
|
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
|
||||||
|
"nesting": {
|
||||||
|
"label": "İç içe etiketler",
|
||||||
|
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
|
||||||
|
},
|
||||||
|
"parent_field": "Üst etiket",
|
||||||
|
"no_parent": "Üst etiket yok",
|
||||||
|
"too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)",
|
||||||
|
"has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.",
|
||||||
|
"has_children_delete": "Önce bunun altındaki etiketleri kaldırın",
|
||||||
|
"visibility_field": "Kenar çubuğunda görünürlük",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Göster",
|
||||||
|
"unread": "Okunmamış varsa göster",
|
||||||
|
"hide": "Gizle"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Bildirim sesini test et",
|
"test_sound": "Bildirim sesini test et",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"mark_as_spam": "Spam bildir",
|
"mark_as_spam": "Spam bildir",
|
||||||
"not_spam": "Spam değil",
|
"not_spam": "Spam değil",
|
||||||
"color_tag": "Etiket",
|
"tag": "Etiket",
|
||||||
"remove_color": "Etiketi kaldır",
|
|
||||||
"items_selected": "{count} e-posta seçildi",
|
"items_selected": "{count} e-posta seçildi",
|
||||||
"edit_draft": "Taslağı Düzenle",
|
"edit_draft": "Taslağı Düzenle",
|
||||||
"cancel_scheduled_send": "Göndermeyi iptal et",
|
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Скинути",
|
"demo_reset": "Скинути",
|
||||||
"demo_tour": "Тур",
|
"demo_tour": "Тур",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
|
"show_all_tags": "Показати всі ({count})",
|
||||||
|
"show_fewer_tags": "Показати менше",
|
||||||
"folders": "Папки",
|
"folders": "Папки",
|
||||||
"mail": "Пошта",
|
"mail": "Пошта",
|
||||||
"nav_label": "Навігація",
|
"nav_label": "Навігація",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Роздрукувати",
|
"print": "Роздрукувати",
|
||||||
"view_source": "Переглянути джерело",
|
"view_source": "Переглянути джерело",
|
||||||
"export_email": "Експортувати як .eml",
|
"export_email": "Експортувати як .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Імпорт .eml або .zip",
|
"import_email": "Імпорт .eml або .zip",
|
||||||
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
||||||
"email_source": "Джерело електронної пошти",
|
"email_source": "Джерело електронної пошти",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Переглянути контакт",
|
"view_contact": "Переглянути контакт",
|
||||||
"message_details": "Деталі повідомлення",
|
"message_details": "Деталі повідомлення",
|
||||||
"more_reply_options": "Більше варіантів відповіді",
|
"more_reply_options": "Більше варіантів відповіді",
|
||||||
"set_color": "Встановити тег",
|
"set_tag": "Встановити тег",
|
||||||
"tag": "Тег",
|
"tag": "Тег",
|
||||||
"more_actions": "Більше дій",
|
"more_actions": "Більше дій",
|
||||||
"previous": "попередня",
|
"previous": "попередня",
|
||||||
"next": "Далі",
|
"next": "Далі",
|
||||||
"move_to": "Перейти до...",
|
"move_to": "Перейти до...",
|
||||||
"remove_color": "Видалити тег",
|
"remove_tag": "Видалити тег",
|
||||||
|
"tag_filter_placeholder": "Фільтр тегів",
|
||||||
|
"tag_no_matches": "Немає відповідних тегів",
|
||||||
"more_count": "+ ще {count}",
|
"more_count": "+ ще {count}",
|
||||||
"characters_count": "{count} символів",
|
"characters_count": "{count} символів",
|
||||||
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID повідомлення",
|
"message_id": "ID повідомлення",
|
||||||
"list_info": "Інформація про список"
|
"list_info": "Інформація про список"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Кольоровий тег",
|
|
||||||
"red": "Червоний",
|
|
||||||
"orange": "Помаранчевий",
|
|
||||||
"yellow": "Жовтий",
|
|
||||||
"green": "Зелений",
|
|
||||||
"blue": "Синій",
|
|
||||||
"purple": "Фіолетовий",
|
|
||||||
"pink": "Рожевий",
|
|
||||||
"none": "Жодного"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Відповісти (р)",
|
"reply": "Відповісти (р)",
|
||||||
"reply_all": "Відповісти всім (а)",
|
"reply_all": "Відповісти всім (а)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "Ключові слова електронної пошти",
|
"title": "Ключові слова електронної пошти",
|
||||||
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
|
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
|
||||||
"add_keyword": "Додати ключове слово",
|
"add_keyword": "Додати ключове слово",
|
||||||
"reset_defaults": "Скинути до значень за замовчуванням",
|
|
||||||
"label_field": "Відображуване ім'я",
|
"label_field": "Відображуване ім'я",
|
||||||
"label_placeholder": "напр. Робота, Особиста, Терміново",
|
"label_placeholder": "напр. Робота, Особиста, Терміново",
|
||||||
"id_field": "ID ключового слова",
|
"id_field": "ID ключового слова",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "додати",
|
"add": "додати",
|
||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
||||||
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах"
|
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
|
||||||
|
"nesting": {
|
||||||
|
"label": "Вкладені теги",
|
||||||
|
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
|
||||||
|
},
|
||||||
|
"parent_field": "Батьківський тег",
|
||||||
|
"no_parent": "Без батьківського тега",
|
||||||
|
"too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)",
|
||||||
|
"has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.",
|
||||||
|
"has_children_delete": "Спочатку видаліть теги, вкладені в цей",
|
||||||
|
"visibility_field": "Видимість на бічній панелі",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Показувати",
|
||||||
|
"unread": "Показувати за непрочитаних",
|
||||||
|
"hide": "Приховувати"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Тестовий звук сповіщення",
|
"test_sound": "Тестовий звук сповіщення",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"mark_as_spam": "Повідомити про спам",
|
"mark_as_spam": "Повідомити про спам",
|
||||||
"not_spam": "Не спам",
|
"not_spam": "Не спам",
|
||||||
"color_tag": "Мітка",
|
"tag": "Мітка",
|
||||||
"remove_color": "Видалити мітку",
|
|
||||||
"items_selected": "Вибрано електронних листів: {count}",
|
"items_selected": "Вибрано електронних листів: {count}",
|
||||||
"edit_draft": "Редагувати чернетку",
|
"edit_draft": "Редагувати чернетку",
|
||||||
"cancel_scheduled_send": "Скасувати надсилання",
|
"cancel_scheduled_send": "Скасувати надсилання",
|
||||||
|
|||||||
+24
-17
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "重置",
|
"demo_reset": "重置",
|
||||||
"demo_tour": "引导",
|
"demo_tour": "引导",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
|
"show_all_tags": "显示全部({count})",
|
||||||
|
"show_fewer_tags": "收起",
|
||||||
"folders": "文件夹",
|
"folders": "文件夹",
|
||||||
"mail": "邮件",
|
"mail": "邮件",
|
||||||
"nav_label": "导航",
|
"nav_label": "导航",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "打印",
|
"print": "打印",
|
||||||
"view_source": "查看源码",
|
"view_source": "查看源码",
|
||||||
"export_email": "导出为 .eml",
|
"export_email": "导出为 .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "导入 .eml 或 .zip",
|
"import_email": "导入 .eml 或 .zip",
|
||||||
"keyboard_shortcuts": "键盘快捷键(?)",
|
"keyboard_shortcuts": "键盘快捷键(?)",
|
||||||
"email_source": "邮件源码",
|
"email_source": "邮件源码",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "查看联系人",
|
"view_contact": "查看联系人",
|
||||||
"message_details": "邮件详情",
|
"message_details": "邮件详情",
|
||||||
"more_reply_options": "更多回复选项",
|
"more_reply_options": "更多回复选项",
|
||||||
"set_color": "设置颜色标签",
|
"set_tag": "设置颜色标签",
|
||||||
"tag": "标签",
|
"tag": "标签",
|
||||||
"more_actions": "更多操作",
|
"more_actions": "更多操作",
|
||||||
"previous": "上一封",
|
"previous": "上一封",
|
||||||
"next": "下一封",
|
"next": "下一封",
|
||||||
"move_to": "移动到…",
|
"move_to": "移动到…",
|
||||||
"remove_color": "删除标签",
|
"remove_tag": "删除标签",
|
||||||
|
"tag_filter_placeholder": "筛选标签",
|
||||||
|
"tag_no_matches": "没有匹配的标签",
|
||||||
"more_count": "+{count} 更多",
|
"more_count": "+{count} 更多",
|
||||||
"characters_count": "{count} 个字符",
|
"characters_count": "{count} 个字符",
|
||||||
"quick_reply_placeholder": "快速回复...",
|
"quick_reply_placeholder": "快速回复...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "消息 ID",
|
"message_id": "消息 ID",
|
||||||
"list_info": "邮件列表信息"
|
"list_info": "邮件列表信息"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "颜色标签",
|
|
||||||
"red": "红色",
|
|
||||||
"orange": "橙色",
|
|
||||||
"yellow": "黄色",
|
|
||||||
"green": "绿色",
|
|
||||||
"blue": "蓝色",
|
|
||||||
"purple": "紫色",
|
|
||||||
"pink": "粉色",
|
|
||||||
"none": "无"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "回复 (r)",
|
"reply": "回复 (r)",
|
||||||
"reply_all": "全部回复 (a)",
|
"reply_all": "全部回复 (a)",
|
||||||
@@ -1015,7 +1009,6 @@
|
|||||||
"title": "电子邮件标签",
|
"title": "电子邮件标签",
|
||||||
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
||||||
"add_keyword": "添加标签",
|
"add_keyword": "添加标签",
|
||||||
"reset_defaults": "重置为默认值",
|
|
||||||
"label_field": "显示名称",
|
"label_field": "显示名称",
|
||||||
"label_placeholder": "例如工作、个人、紧急",
|
"label_placeholder": "例如工作、个人、紧急",
|
||||||
"id_field": "标签ID",
|
"id_field": "标签ID",
|
||||||
@@ -1028,7 +1021,22 @@
|
|||||||
"add": "添加",
|
"add": "添加",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"migrating": "正在更新现有邮件的标签…",
|
"migrating": "正在更新现有邮件的标签…",
|
||||||
"migration_error": "更新现有邮件的标签失败"
|
"migration_error": "更新现有邮件的标签失败",
|
||||||
|
"nesting": {
|
||||||
|
"label": "嵌套标签",
|
||||||
|
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
|
||||||
|
},
|
||||||
|
"parent_field": "上级标签",
|
||||||
|
"no_parent": "无上级标签",
|
||||||
|
"too_long": "此标签路径过长(最多 {max} 个字符)",
|
||||||
|
"has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。",
|
||||||
|
"has_children_delete": "请先删除嵌套在此标签下的标签",
|
||||||
|
"visibility_field": "侧边栏显示",
|
||||||
|
"visibility": {
|
||||||
|
"show": "显示",
|
||||||
|
"unread": "有未读时显示",
|
||||||
|
"hide": "隐藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "测试通知声音",
|
"test_sound": "测试通知声音",
|
||||||
@@ -2033,8 +2041,7 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"mark_as_spam": "举报垃圾邮件",
|
"mark_as_spam": "举报垃圾邮件",
|
||||||
"not_spam": "不是垃圾邮件",
|
"not_spam": "不是垃圾邮件",
|
||||||
"color_tag": "标签",
|
"tag": "标签",
|
||||||
"remove_color": "删除标签",
|
|
||||||
"items_selected": "已选择 {count} 封邮件",
|
"items_selected": "已选择 {count} 封邮件",
|
||||||
"edit_draft": "编辑草稿",
|
"edit_draft": "编辑草稿",
|
||||||
"cancel_scheduled_send": "取消发送",
|
"cancel_scheduled_send": "取消发送",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const nextConfig: NextConfig = {
|
|||||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
NEXT_PUBLIC_BASE_PATH: basePath,
|
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||||
|
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+11
-12
@@ -73,6 +73,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
@@ -6279,7 +6280,6 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -7976,17 +7976,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
|
||||||
"version": "0.5.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
|
||||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -9666,6 +9655,16 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tw-animate-css": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/Wombosvideo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE } from '../settings-store';
|
import { useSettingsStore, DEFAULT_KEYWORDS, DEV_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store';
|
||||||
import type { KeywordDefinition } from '../settings-store';
|
import type { KeywordDefinition } from '../settings-store';
|
||||||
|
|
||||||
describe('settings-store keywords', () => {
|
describe('settings-store keywords', () => {
|
||||||
@@ -16,7 +16,7 @@ describe('settings-store keywords', () => {
|
|||||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||||
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
|
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
|
||||||
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
|
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
|
||||||
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy();
|
expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,17 +24,67 @@ describe('settings-store keywords', () => {
|
|||||||
const ids = DEFAULT_KEYWORDS.map((k) => k.id);
|
const ids = DEFAULT_KEYWORDS.map((k) => k.id);
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ships no nested tag, which is opt-in', () => {
|
||||||
|
DEFAULT_KEYWORDS.forEach((kw) => expect(kw.id).not.toContain('/'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEV_KEYWORDS', () => {
|
||||||
|
it('every nested tag has its parent defined, so the tree has no gaps', () => {
|
||||||
|
const ids = new Set(DEV_KEYWORDS.map((k) => k.id));
|
||||||
|
DEV_KEYWORDS.forEach((kw) => {
|
||||||
|
const cut = kw.id.lastIndexOf('/');
|
||||||
|
if (cut > 0) expect(ids, `orphan: ${kw.id}`).toContain(kw.id.slice(0, cut));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests deeply enough to exercise the tree', () => {
|
||||||
|
const depths = DEV_KEYWORDS.map((k) => k.id.split('/').length);
|
||||||
|
expect(Math.max(...depths)).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each dev keyword has a valid palette color and a unique id', () => {
|
||||||
|
const ids = DEV_KEYWORDS.map((k) => k.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
DEV_KEYWORDS.forEach((kw) => expect(KEYWORD_PALETTE[kw.color]).toBeDefined());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('KEYWORD_PALETTE', () => {
|
describe('KEYWORD_PALETTE', () => {
|
||||||
it('has 13 colors', () => {
|
it('has a lighter, base and darker shade of every hue', () => {
|
||||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13);
|
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) => {
|
Object.values(KEYWORD_PALETTE).forEach((entry) => {
|
||||||
expect(entry.dot).toMatch(/^bg-/);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -156,4 +206,30 @@ describe('settings-store keywords', () => {
|
|||||||
expect(kw?.label).toBe('Scarlet');
|
expect(kw?.label).toBe('Scarlet');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getKeywordVisibility', () => {
|
||||||
|
it('treats a tag stored before visibility was configurable as always shown', () => {
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red' })).toBe('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the stored choice when there is one', () => {
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'unread' })).toBe('unread');
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'hide' })).toBe('hide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nestedTags', () => {
|
||||||
|
it('is off by default', () => {
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
expect(useSettingsStore.getState().nestedTags).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is included in exported settings', () => {
|
||||||
|
useSettingsStore.getState().updateSetting('nestedTags', true);
|
||||||
|
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
|
||||||
|
nestedTags?: boolean;
|
||||||
|
};
|
||||||
|
expect(exported.nestedTags).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+102
-16
@@ -101,10 +101,19 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
|||||||
{ id: 'contacts', labelKey: 'contacts' },
|
{ id: 'contacts', labelKey: 'contacts' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Whether a tag shows in the sidebar always, only when it has unread mail, or never. */
|
||||||
|
export type KeywordVisibility = 'show' | 'hide' | 'unread';
|
||||||
|
|
||||||
export interface KeywordDefinition {
|
export interface KeywordDefinition {
|
||||||
id: string; // Used as JMAP keyword suffix: $label:<id>
|
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||||
label: string; // Display name
|
label: string; // Display name
|
||||||
color: string; // Key from KEYWORD_PALETTE
|
color: string; // Key from KEYWORD_PALETTE
|
||||||
|
visibility?: KeywordVisibility; // Absent on tags stored before this was configurable
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the sidebar visibility of a tag, defaulting to always shown. */
|
||||||
|
export function getKeywordVisibility(keyword: KeywordDefinition): KeywordVisibility {
|
||||||
|
return keyword.visibility ?? 'show';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarApp {
|
export interface SidebarApp {
|
||||||
@@ -116,23 +125,86 @@ export interface SidebarApp {
|
|||||||
showOnMobile: boolean;
|
showOnMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available color palette for keywords
|
export interface KeywordColor {
|
||||||
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
|
/** Solid swatch: the dot form and the settings swatches. */
|
||||||
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
|
dot: string;
|
||||||
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
|
/** The same solid colour as `dot`, for glyphs that take a text colour. */
|
||||||
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
icon: string;
|
||||||
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
|
/** Lozenge background. */
|
||||||
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
|
fill: string;
|
||||||
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
|
/** Lozenge border. */
|
||||||
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
|
border: string;
|
||||||
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
|
/** Lozenge text. */
|
||||||
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
text: string;
|
||||||
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
/** Full-row wash when `tintListRowsByTag` is on. */
|
||||||
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
|
rowTint: string;
|
||||||
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' },
|
|
||||||
|
/**
|
||||||
|
* 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;
|
} 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[] = [
|
export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
||||||
{ id: 'red', label: 'Red', color: 'red' },
|
{ id: 'red', label: 'Red', color: 'red' },
|
||||||
{ id: 'orange', label: 'Orange', color: 'orange' },
|
{ id: 'orange', label: 'Orange', color: 'orange' },
|
||||||
@@ -143,6 +215,17 @@ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
|||||||
{ id: 'pink', label: 'Pink', color: 'pink' },
|
{ id: 'pink', label: 'Pink', color: 'pink' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEV_KEYWORDS: KeywordDefinition[] = [
|
||||||
|
{ id: 'work', label: 'Work', color: 'blue' },
|
||||||
|
{ id: 'work/clients', label: 'Clients', color: 'teal' },
|
||||||
|
{ id: 'work/clients/acme', label: 'Acme', color: 'green' },
|
||||||
|
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||||
|
{ id: 'personal/finance', label: 'Finance', color: 'amber' },
|
||||||
|
{ id: 'receipts', label: 'Receipts', color: 'gray' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true';
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
// Appearance
|
// Appearance
|
||||||
fontSize: FontSize;
|
fontSize: FontSize;
|
||||||
@@ -287,6 +370,7 @@ interface SettingsState {
|
|||||||
|
|
||||||
// Keywords (labels/tags)
|
// Keywords (labels/tags)
|
||||||
emailKeywords: KeywordDefinition[];
|
emailKeywords: KeywordDefinition[];
|
||||||
|
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
|
||||||
|
|
||||||
// Attachment Reminder
|
// Attachment Reminder
|
||||||
attachmentReminderEnabled: boolean;
|
attachmentReminderEnabled: boolean;
|
||||||
@@ -484,7 +568,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
folderIcons: {} as Record<string, string>,
|
folderIcons: {} as Record<string, string>,
|
||||||
|
|
||||||
// Keywords
|
// Keywords
|
||||||
emailKeywords: DEFAULT_KEYWORDS,
|
emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS,
|
||||||
|
nestedTags: USING_MOCK_SERVER,
|
||||||
|
|
||||||
// Attachment Reminder
|
// Attachment Reminder
|
||||||
attachmentReminderEnabled: true,
|
attachmentReminderEnabled: true,
|
||||||
@@ -661,6 +746,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showFolderTotalCount: state.showFolderTotalCount,
|
showFolderTotalCount: state.showFolderTotalCount,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
emailKeywords: state.emailKeywords,
|
emailKeywords: state.emailKeywords,
|
||||||
|
nestedTags: state.nestedTags,
|
||||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: [
|
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./stores/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: [
|
|
||||||
"-apple-system",
|
|
||||||
"BlinkMacSystemFont",
|
|
||||||
"Inter",
|
|
||||||
"system-ui",
|
|
||||||
"sans-serif",
|
|
||||||
],
|
|
||||||
mono: ["JetBrains Mono", "monospace"],
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"fade-in": "fade-in 0.2s ease-out",
|
|
||||||
"slide-in": "slide-in 0.3s ease-out",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
"fade-in": {
|
|
||||||
"0%": { opacity: "0" },
|
|
||||||
"100%": { opacity: "1" },
|
|
||||||
},
|
|
||||||
"slide-in": {
|
|
||||||
"0%": { transform: "translateY(-10px)", opacity: "0" },
|
|
||||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
} satisfies Config;
|
|
||||||
Reference in New Issue
Block a user