mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: configurable message-list ordering #718
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||
- Tag names and colors live in the browser, while the tags themselves ride on your messages as JMAP keywords. A scan finds keywords no local tag explains and adds them back.
|
||||
- Star or unstar, with a configurable mark-as-read delay
|
||||
- Configurable message-list order: presets (unread first, starred first, tagged first) or up to three custom sort levels, applied server-side through the JMAP sort so the whole folder is ordered, for the Inbox only or every folder
|
||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||
- A refresh button in the list toolbar that spins while the fetch is in flight, alongside the F5 / Ctrl+R / pull-to-refresh gestures it shares a code path with
|
||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||
|
||||
@@ -126,11 +126,17 @@ export function EmailList({
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
// The order the current folder view was fetched in (#718), so thread
|
||||
// grouping mirrors the server order instead of re-sorting the page by date.
|
||||
// Search results and cross-account views are always chronological.
|
||||
const fetchedListOrder = useEmailStore((state) => state.listOrder);
|
||||
const crossView = useEmailStore((state) => state.crossView);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder;
|
||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||
return sortThreadGroups(groups, listOrder);
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, searchQuery, crossView, searchFilters]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getMessageListOrderFor, useSettingsStore } from "@/stores/settings-store";
|
||||
import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
@@ -78,12 +78,15 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [folderLabel, tabId, updateTabTitle]);
|
||||
|
||||
// The configured list order (#718) depends on the folder's role (Inbox-only
|
||||
// by default), and every page of the tab must ask for the same order.
|
||||
const mailboxRole = mailbox?.role;
|
||||
const loadPage = useCallback(async (position: number) => {
|
||||
if (!client) return;
|
||||
const seq = ++fetchSeqRef.current;
|
||||
if (position === 0) setIsLoading(true); else setIsLoadingMore(true);
|
||||
// getEmails never throws - it reports failures as an empty page.
|
||||
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true);
|
||||
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true, undefined, getMessageListOrderFor(mailboxRole));
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setEmails((prev) => {
|
||||
if (position === 0) return result.emails;
|
||||
@@ -93,7 +96,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
|
||||
setTotal(result.total);
|
||||
setHasMore(result.hasMore);
|
||||
if (position === 0) setIsLoading(false); else setIsLoadingMore(false);
|
||||
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage]);
|
||||
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage, mailboxRole]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEmailId(null);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MessageListOrderSettings } from '../message-list-order-settings';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Settings → Appearance → "Message list order" (#718): a preset dropdown over
|
||||
// the same level list the advanced editor exposes, persisted through the
|
||||
// synced settings store.
|
||||
|
||||
describe('MessageListOrderSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
messageListOrder: [],
|
||||
messageListOrderScope: 'inbox',
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
});
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
const presetSelect = () => screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
||||
|
||||
it('shows the chronological preset by default and keeps the editor closed', () => {
|
||||
render(<MessageListOrderSettings />);
|
||||
expect(presetSelect().value).toBe('chronological');
|
||||
expect(screen.queryByTestId('message-list-order-editor')).toBeNull();
|
||||
// No "custom" entry unless the order actually is custom.
|
||||
expect(screen.queryByText('preset.custom')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies a simple preset as a single sort level', () => {
|
||||
render(<MessageListOrderSettings />);
|
||||
fireEvent.change(presetSelect(), { target: { value: 'unread_first' } });
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]);
|
||||
expect(presetSelect().value).toBe('unread_first');
|
||||
|
||||
fireEvent.change(presetSelect(), { target: { value: 'chronological' } });
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the first tag for "tagged first" and lets the tag be changed', () => {
|
||||
render(<MessageListOrderSettings />);
|
||||
fireEvent.change(presetSelect(), { target: { value: 'tagged_first' } });
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([
|
||||
{ criterion: 'tag', direction: 'desc', tagId: DEFAULT_KEYWORDS[0].id },
|
||||
]);
|
||||
|
||||
const tagSelect = screen.getByLabelText('tag_label') as HTMLSelectElement;
|
||||
fireEvent.change(tagSelect, { target: { value: DEFAULT_KEYWORDS[1].id } });
|
||||
expect(useSettingsStore.getState().messageListOrder[0].tagId).toBe(DEFAULT_KEYWORDS[1].id);
|
||||
});
|
||||
|
||||
it('opens the advanced editor on a custom order and reports it as custom', () => {
|
||||
useSettingsStore.setState({
|
||||
messageListOrder: [
|
||||
{ criterion: 'unread', direction: 'desc' },
|
||||
{ criterion: 'starred', direction: 'desc' },
|
||||
],
|
||||
});
|
||||
render(<MessageListOrderSettings />);
|
||||
expect(presetSelect().value).toBe('custom');
|
||||
expect(screen.getByTestId('message-list-order-editor')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-list-order-level-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-list-order-level-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds, edits, reorders and removes levels in the editor', () => {
|
||||
render(<MessageListOrderSettings />);
|
||||
fireEvent.click(screen.getByTestId('message-list-order-advanced-toggle'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]);
|
||||
|
||||
// Switching the criterion resets the direction to that criterion's default.
|
||||
fireEvent.change(screen.getByLabelText('criterion_label'), { target: { value: 'from' } });
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'asc' }]);
|
||||
fireEvent.change(screen.getByLabelText('direction_label'), { target: { value: 'desc' } });
|
||||
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'desc' }]);
|
||||
|
||||
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
|
||||
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
|
||||
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'unread', 'starred']);
|
||||
// Capped at three levels.
|
||||
expect(screen.queryByTestId('message-list-order-add-level')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('move_up')[2]);
|
||||
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'starred', 'unread']);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('remove_level')[0]);
|
||||
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['starred', 'unread']);
|
||||
});
|
||||
|
||||
it('toggles the all-folders scope', () => {
|
||||
render(<MessageListOrderSettings />);
|
||||
fireEvent.click(screen.getByTestId('message-list-order-scope'));
|
||||
expect(useSettingsStore.getState().messageListOrderScope).toBe('all');
|
||||
fireEvent.click(screen.getByTestId('message-list-order-scope'));
|
||||
expect(useSettingsStore.getState().messageListOrderScope).toBe('inbox');
|
||||
});
|
||||
|
||||
it('warns when the server advertises sort options without hasKeyword', () => {
|
||||
useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] });
|
||||
useAuthStore.setState({
|
||||
client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'size']) } as unknown as IJMAPClient,
|
||||
});
|
||||
render(<MessageListOrderSettings />);
|
||||
expect(screen.getByText('unsupported_note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when the server supports keyword sorting or does not say', () => {
|
||||
useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] });
|
||||
useAuthStore.setState({
|
||||
client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'hasKeyword']) } as unknown as IJMAPClient,
|
||||
});
|
||||
const { unmount } = render(<MessageListOrderSettings />);
|
||||
expect(screen.queryByText('unsupported_note')).toBeNull();
|
||||
unmount();
|
||||
|
||||
useAuthStore.setState({ client: { getEmailQuerySortOptions: vi.fn(() => null) } as unknown as IJMAPClient });
|
||||
render(<MessageListOrderSettings />);
|
||||
expect(screen.queryByText('unsupported_note')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { MessageListOrderSettings } from './message-list-order-settings';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -117,6 +118,8 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<MessageListOrderSettings />
|
||||
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
<ToggleSwitch
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowDown, ArrowUp, GripVertical, Plus, X } from 'lucide-react';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MAX_SORT_LEVELS,
|
||||
ORDER_PRESETS,
|
||||
SORT_CRITERIA,
|
||||
defaultDirection,
|
||||
detectPreset,
|
||||
isKeywordCriterion,
|
||||
presetLevels,
|
||||
sanitizeSortLevels,
|
||||
type OrderPreset,
|
||||
type SortCriterion,
|
||||
type SortDirection,
|
||||
type SortLevel,
|
||||
} from '@/lib/message-list-order';
|
||||
|
||||
/** Semantic direction labels per criterion: [desc, asc] translation keys. */
|
||||
const DIRECTION_LABELS: Record<SortCriterion, [string, string]> = {
|
||||
unread: ['unread_first', 'read_first'],
|
||||
starred: ['starred_first', 'unstarred_first'],
|
||||
tag: ['tagged_first', 'untagged_first'],
|
||||
receivedAt: ['newest_first', 'oldest_first'],
|
||||
sentAt: ['newest_first', 'oldest_first'],
|
||||
from: ['z_to_a', 'a_to_z'],
|
||||
subject: ['z_to_a', 'a_to_z'],
|
||||
size: ['largest_first', 'smallest_first'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-fetches the current folder so a changed order shows up without leaving
|
||||
* settings and coming back (the Pro interface keeps the mail tab mounted).
|
||||
*/
|
||||
function refetchCurrentList() {
|
||||
const client = useAuthStore.getState().client;
|
||||
const store = useEmailStore.getState();
|
||||
if (client && store.selectedMailbox) {
|
||||
void store.fetchEmails(client, store.selectedMailbox, { background: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Appearance → "Message list order" (#718). A preset dropdown for
|
||||
* the common cases and an advanced editor for up to MAX_SORT_LEVELS ordered
|
||||
* {criterion, direction} levels, mapped onto the JMAP Email/query sort.
|
||||
*/
|
||||
export function MessageListOrderSettings() {
|
||||
const t = useTranslations('settings.appearance.message_list_order');
|
||||
const { messageListOrder, messageListOrderScope, emailKeywords, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const preset = detectPreset(messageListOrder);
|
||||
const [advanced, setAdvanced] = useState(preset === 'custom');
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
|
||||
// A server that lists its sort options without hasKeyword cannot order by
|
||||
// read state, stars or tags; say so rather than silently showing a
|
||||
// chronological list the user believes is prioritised.
|
||||
const sortOptions = client?.getEmailQuerySortOptions?.() ?? null;
|
||||
const keywordSortUnsupported = sortOptions !== null && !sortOptions.includes('hasKeyword');
|
||||
|
||||
if (isSettingHidden('messageListOrder')) return null;
|
||||
const locked = isSettingLocked('messageListOrder');
|
||||
|
||||
const apply = (next: SortLevel[]) => {
|
||||
updateSetting('messageListOrder', sanitizeSortLevels(next));
|
||||
refetchCurrentList();
|
||||
};
|
||||
|
||||
const firstTagId = emailKeywords[0]?.id;
|
||||
const presetTagId = messageListOrder.find((l) => l.criterion === 'tag')?.tagId ?? firstTagId;
|
||||
|
||||
const onPresetChange = (value: string) => {
|
||||
const next = value as OrderPreset;
|
||||
if (next === 'custom') return;
|
||||
apply(presetLevels(next, presetTagId));
|
||||
};
|
||||
|
||||
const updateLevel = (index: number, patch: Partial<SortLevel>) => {
|
||||
apply(messageListOrder.map((level, i) => {
|
||||
if (i !== index) return level;
|
||||
const merged = { ...level, ...patch };
|
||||
if (patch.criterion && patch.criterion !== level.criterion) {
|
||||
merged.direction = defaultDirection(patch.criterion);
|
||||
if (patch.criterion === 'tag') merged.tagId = merged.tagId ?? firstTagId;
|
||||
else delete merged.tagId;
|
||||
}
|
||||
return merged;
|
||||
}));
|
||||
};
|
||||
|
||||
const moveLevel = (from: number, to: number) => {
|
||||
if (to < 0 || to >= messageListOrder.length || from === to) return;
|
||||
const next = [...messageListOrder];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
apply(next);
|
||||
};
|
||||
|
||||
const removeLevel = (index: number) => apply(messageListOrder.filter((_, i) => i !== index));
|
||||
|
||||
const usedCriteria = new Set(messageListOrder.map((l) => l.criterion));
|
||||
const addLevel = () => {
|
||||
if (messageListOrder.length >= MAX_SORT_LEVELS) return;
|
||||
const criterion = SORT_CRITERIA.find((c) => !usedCriteria.has(c) && (c !== 'tag' || firstTagId));
|
||||
if (!criterion) return;
|
||||
const level: SortLevel = { criterion, direction: defaultDirection(criterion) };
|
||||
if (criterion === 'tag') level.tagId = firstTagId;
|
||||
apply([...messageListOrder, level]);
|
||||
};
|
||||
|
||||
const presetOptions = [
|
||||
...ORDER_PRESETS
|
||||
.filter((p) => p !== 'tagged_first' || emailKeywords.length > 0)
|
||||
.map((p) => ({ value: p, label: t(`preset.${p}`) })),
|
||||
...(preset === 'custom' ? [{ value: 'custom', label: t('preset.custom') }] : []),
|
||||
];
|
||||
|
||||
const tagOptions = emailKeywords.map((k) => ({ value: k.id, label: k.label }));
|
||||
|
||||
const criterionOptions = (current: SortCriterion) =>
|
||||
SORT_CRITERIA
|
||||
.filter((c) => c === current || (!usedCriteria.has(c) && (c !== 'tag' || emailKeywords.length > 0)))
|
||||
.map((c) => ({ value: c, label: t(`criteria.${c}`) }));
|
||||
|
||||
const directionOptions = (criterion: SortCriterion) => {
|
||||
const [desc, asc] = DIRECTION_LABELS[criterion];
|
||||
return [
|
||||
{ value: 'desc', label: t(`direction.${desc}`) },
|
||||
{ value: 'asc', label: t(`direction.${asc}`) },
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingItem label={t('label')} description={t('description')} locked={locked}>
|
||||
<div className="flex flex-col items-start sm:items-end gap-2">
|
||||
<Select value={preset} onChange={onPresetChange} options={presetOptions} disabled={locked} />
|
||||
{preset === 'tagged_first' && !advanced && tagOptions.length > 0 && (
|
||||
<Select
|
||||
value={presetTagId ?? ''}
|
||||
onChange={(tagId) => apply(presetLevels('tagged_first', tagId))}
|
||||
options={tagOptions}
|
||||
disabled={locked}
|
||||
ariaLabel={t('tag_label')}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvanced((v) => !v)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
aria-expanded={advanced}
|
||||
data-testid="message-list-order-advanced-toggle"
|
||||
>
|
||||
{advanced ? t('advanced_hide') : t('advanced_show')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{advanced && (
|
||||
<div className="py-3 border-b border-border" data-testid="message-list-order-editor">
|
||||
<ol className="flex flex-col gap-2">
|
||||
{messageListOrder.map((level, index) => (
|
||||
<li
|
||||
key={`${level.criterion}-${level.tagId ?? ''}-${index}`}
|
||||
draggable={!locked}
|
||||
onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => { e.preventDefault(); if (dragIndex !== null) moveLevel(dragIndex, index); setDragIndex(null); }}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5',
|
||||
dragIndex === index && 'opacity-50',
|
||||
)}
|
||||
data-testid={`message-list-order-level-${index}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground w-10 flex-shrink-0">
|
||||
<GripVertical className="w-4 h-4 cursor-grab" aria-hidden="true" />
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Select
|
||||
value={level.criterion}
|
||||
onChange={(criterion) => updateLevel(index, { criterion: criterion as SortCriterion })}
|
||||
options={criterionOptions(level.criterion)}
|
||||
disabled={locked}
|
||||
ariaLabel={t('criterion_label', { n: index + 1 })}
|
||||
/>
|
||||
{level.criterion === 'tag' && (
|
||||
<Select
|
||||
value={level.tagId ?? ''}
|
||||
onChange={(tagId) => updateLevel(index, { tagId })}
|
||||
options={tagOptions}
|
||||
disabled={locked}
|
||||
ariaLabel={t('tag_label')}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={level.direction}
|
||||
onChange={(direction) => updateLevel(index, { direction: direction as SortDirection })}
|
||||
options={directionOptions(level.criterion)}
|
||||
disabled={locked}
|
||||
ariaLabel={t('direction_label', { n: index + 1 })}
|
||||
/>
|
||||
<span className="flex items-center gap-0.5 ms-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(index, index - 1)}
|
||||
disabled={locked || index === 0}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-40"
|
||||
aria-label={t('move_up')}
|
||||
title={t('move_up')}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(index, index + 1)}
|
||||
disabled={locked || index === messageListOrder.length - 1}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-40"
|
||||
aria-label={t('move_down')}
|
||||
title={t('move_down')}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLevel(index)}
|
||||
disabled={locked}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-40"
|
||||
aria-label={t('remove_level')}
|
||||
title={t('remove_level')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||
{messageListOrder.length < MAX_SORT_LEVELS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addLevel}
|
||||
disabled={locked}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-40"
|
||||
data-testid="message-list-order-add-level"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('add_level')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{t('tiebreak_note')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keywordSortUnsupported && messageListOrder.some((l) => isKeywordCriterion(l.criterion)) && (
|
||||
<p className="py-2 text-xs text-amber-600 dark:text-amber-400" role="status">
|
||||
{t('unsupported_note')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('scope.label')} description={t('scope.description')} locked={locked}>
|
||||
<ToggleSwitch
|
||||
checked={messageListOrderScope === 'all'}
|
||||
onChange={(checked) => {
|
||||
updateSetting('messageListOrderScope', checked ? 'all' : 'inbox');
|
||||
refetchCurrentList();
|
||||
}}
|
||||
disabled={locked}
|
||||
testId="message-list-order-scope"
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import type { SortLevel } from '../message-list-order';
|
||||
|
||||
// Configurable message-list ordering (#718) maps onto Email/query sort
|
||||
// comparators. Stalwart 0.16.8 reads `isAscending` on hasKeyword the other
|
||||
// way round from RFC 8621, so the client probes the server once per account
|
||||
// and flips the keyword comparators accordingly - instead of showing users the
|
||||
// inverse of what they picked.
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }];
|
||||
|
||||
function makeSession(mailCaps: Record<string, unknown> = {}) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': mailCaps } } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const seenEmail = { id: 'read-1', keywords: { $seen: true }, receivedAt: '2026-01-02T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
|
||||
const unseenEmail = { id: 'unread-1', keywords: {}, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
|
||||
|
||||
/**
|
||||
* Answers the polarity probe (call ids asc/desc/get) per `server`, and every
|
||||
* folder query (call ids 0/1) with a fixed page, optionally refusing keyword
|
||||
* comparators with unsupportedSort.
|
||||
*/
|
||||
function replyFor(server: 'rfc' | 'inverted' | 'homogeneous', opts: { refuseKeywordSort?: boolean } = {}) {
|
||||
return (methodCalls: MethodCall[]) => {
|
||||
if (methodCalls[0][2] === 'asc') {
|
||||
const ascFirst = server === 'inverted' ? seenEmail : unseenEmail;
|
||||
const descFirst = server === 'homogeneous' ? ascFirst : (server === 'inverted' ? unseenEmail : seenEmail);
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/query', { ids: [ascFirst.id] }, 'asc'],
|
||||
['Email/query', { ids: [descFirst.id] }, 'desc'],
|
||||
['Email/get', { list: [{ id: ascFirst.id, keywords: ascFirst.keywords }] }, 'get'],
|
||||
],
|
||||
};
|
||||
}
|
||||
const sort = methodCalls[0][1].sort as Array<{ property: string }>;
|
||||
if (opts.refuseKeywordSort && sort.some(s => s.property === 'hasKeyword')) {
|
||||
return { methodResponses: [['error', { type: 'unsupportedSort' }, '0'], ['error', { type: 'invalidResultReference' }, '1']] };
|
||||
}
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/query', { ids: [seenEmail.id, unseenEmail.id], total: 2 }, '0'],
|
||||
['Email/get', { list: [seenEmail, unseenEmail] }, '1'],
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe('JMAPClient list ordering', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function connectedClient(mailCaps?: Record<string, unknown>): Promise<JMAPClient> {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(mailCaps)));
|
||||
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
|
||||
function recordRequests(reply: (methodCalls: MethodCall[]) => unknown) {
|
||||
const sent: MethodCall[][] = [];
|
||||
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
sent.push(body.methodCalls);
|
||||
return jsonResponse(reply(body.methodCalls));
|
||||
}) as never);
|
||||
return sent;
|
||||
}
|
||||
|
||||
const folderQueries = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === '0').map(calls => calls[0][1].sort);
|
||||
const probes = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === 'asc');
|
||||
|
||||
it('sends the RFC polarity to a server that follows RFC 8621', async () => {
|
||||
const client = await connectedClient();
|
||||
const sent = recordRequests(replyFor('rfc'));
|
||||
|
||||
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
||||
|
||||
expect(probes(sent)).toHaveLength(1);
|
||||
expect(folderQueries(sent)).toEqual([[
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]]);
|
||||
});
|
||||
|
||||
it('flips the keyword comparators for a server with inverted polarity (Stalwart 0.16.8)', async () => {
|
||||
const client = await connectedClient();
|
||||
const sent = recordRequests(replyFor('inverted'));
|
||||
|
||||
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
||||
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
|
||||
|
||||
// One probe per account and session, not per page.
|
||||
expect(probes(sent)).toHaveLength(1);
|
||||
expect(folderQueries(sent)).toEqual([
|
||||
[
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
],
|
||||
[
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
],
|
||||
]);
|
||||
// The within-page safety sort mirrors the requested order.
|
||||
expect(emails.map(e => e.id)).toEqual(['unread-1', 'read-1']);
|
||||
});
|
||||
|
||||
it('falls back to the RFC polarity when every message is in the same read state', async () => {
|
||||
const client = await connectedClient();
|
||||
const sent = recordRequests(replyFor('homogeneous'));
|
||||
|
||||
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
||||
|
||||
expect(folderQueries(sent)[0]).toEqual([
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips the probe and the comparators when the order needs no keyword sort', async () => {
|
||||
const client = await connectedClient();
|
||||
const sent = recordRequests(replyFor('rfc'));
|
||||
|
||||
await client.getEmails('inbox', undefined, 50, 0, undefined, false, undefined, [{ criterion: 'size', direction: 'desc' }]);
|
||||
|
||||
expect(probes(sent)).toHaveLength(0);
|
||||
expect(folderQueries(sent)).toEqual([[
|
||||
{ property: 'size', isAscending: false },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]]);
|
||||
});
|
||||
|
||||
it('omits keyword comparators when the server advertises sort options without hasKeyword', async () => {
|
||||
const client = await connectedClient({ emailQuerySortOptions: ['receivedAt', 'size'] });
|
||||
const sent = recordRequests(replyFor('rfc'));
|
||||
|
||||
expect(client.getEmailQuerySortOptions()).toEqual(['receivedAt', 'size']);
|
||||
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
||||
|
||||
expect(probes(sent)).toHaveLength(0);
|
||||
expect(folderQueries(sent)).toEqual([[{ property: 'receivedAt', isAscending: false }]]);
|
||||
expect(emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retries without keyword comparators after an unsupportedSort refusal and remembers it', async () => {
|
||||
const client = await connectedClient();
|
||||
const sent = recordRequests(replyFor('rfc', { refuseKeywordSort: true }));
|
||||
|
||||
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
||||
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
|
||||
|
||||
expect(emails).toHaveLength(2);
|
||||
expect(folderQueries(sent)).toEqual([
|
||||
[
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
],
|
||||
[{ property: 'receivedAt', isAscending: false }],
|
||||
[{ property: 'receivedAt', isAscending: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEmailSort,
|
||||
compareEmails,
|
||||
detectPreset,
|
||||
hasKeywordLevels,
|
||||
isDefaultOrder,
|
||||
keywordFirst,
|
||||
levelKeyword,
|
||||
orderForMailbox,
|
||||
presetLevels,
|
||||
sanitizeSortLevels,
|
||||
MAX_SORT_LEVELS,
|
||||
type SortLevel,
|
||||
} from '../message-list-order';
|
||||
import type { Email } from '../jmap/types';
|
||||
|
||||
const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }];
|
||||
const STARRED_FIRST: SortLevel[] = [{ criterion: 'starred', direction: 'desc' }];
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: overrides.id ?? Math.random().toString(36).slice(2),
|
||||
threadId: 't',
|
||||
mailboxIds: {},
|
||||
keywords: {},
|
||||
size: 100,
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sanitizeSortLevels', () => {
|
||||
it('returns an empty list for anything that is not an array', () => {
|
||||
expect(sanitizeSortLevels(undefined)).toEqual([]);
|
||||
expect(sanitizeSortLevels('unread')).toEqual([]);
|
||||
expect(sanitizeSortLevels({ criterion: 'unread' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops unknown criteria, defaults a missing direction and caps the list', () => {
|
||||
const levels = sanitizeSortLevels([
|
||||
{ criterion: 'unread' },
|
||||
{ criterion: 'importance', direction: 'desc' },
|
||||
{ criterion: 'from', direction: 'sideways' },
|
||||
{ criterion: 'size', direction: 'asc' },
|
||||
{ criterion: 'subject', direction: 'asc' },
|
||||
]);
|
||||
expect(levels).toEqual([
|
||||
{ criterion: 'unread', direction: 'desc' },
|
||||
{ criterion: 'from', direction: 'asc' },
|
||||
{ criterion: 'size', direction: 'asc' },
|
||||
]);
|
||||
expect(levels).toHaveLength(MAX_SORT_LEVELS);
|
||||
});
|
||||
|
||||
it('drops a tag level without a tag id and duplicate criteria', () => {
|
||||
expect(sanitizeSortLevels([
|
||||
{ criterion: 'tag', direction: 'desc' },
|
||||
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
|
||||
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
|
||||
{ criterion: 'tag', direction: 'asc', tagId: 'blue' },
|
||||
{ criterion: 'unread', direction: 'desc' },
|
||||
{ criterion: 'unread', direction: 'asc' },
|
||||
])).toEqual([
|
||||
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
|
||||
{ criterion: 'tag', direction: 'asc', tagId: 'blue' },
|
||||
{ criterion: 'unread', direction: 'desc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyword semantics', () => {
|
||||
it('maps the boolean criteria onto JMAP keywords', () => {
|
||||
expect(levelKeyword({ criterion: 'unread', direction: 'desc' })).toBe('$seen');
|
||||
expect(levelKeyword({ criterion: 'starred', direction: 'desc' })).toBe('$flagged');
|
||||
expect(levelKeyword({ criterion: 'tag', direction: 'desc', tagId: 'red' })).toBe('$label:red');
|
||||
expect(levelKeyword({ criterion: 'receivedAt', direction: 'desc' })).toBeNull();
|
||||
});
|
||||
|
||||
it('"unread first" means messages WITHOUT $seen first', () => {
|
||||
expect(keywordFirst({ criterion: 'unread', direction: 'desc' })).toBe(false);
|
||||
expect(keywordFirst({ criterion: 'unread', direction: 'asc' })).toBe(true);
|
||||
expect(keywordFirst({ criterion: 'starred', direction: 'desc' })).toBe(true);
|
||||
expect(keywordFirst({ criterion: 'tag', direction: 'desc', tagId: 'x' })).toBe(true);
|
||||
});
|
||||
|
||||
it('knows when a keyword comparator is needed', () => {
|
||||
expect(hasKeywordLevels([])).toBe(false);
|
||||
expect(hasKeywordLevels([], true)).toBe(true);
|
||||
expect(hasKeywordLevels([{ criterion: 'size', direction: 'desc' }])).toBe(false);
|
||||
expect(hasKeywordLevels(UNREAD_FIRST)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEmailSort', () => {
|
||||
it('is plain newest-first with no levels', () => {
|
||||
expect(buildEmailSort([])).toEqual([{ property: 'receivedAt', isAscending: false }]);
|
||||
});
|
||||
|
||||
it('follows the RFC 8621 polarity by default: has-keyword first is descending', () => {
|
||||
// RFC example: {someInThreadHaveKeyword, $flagged, isAscending: false} sorts flagged first.
|
||||
expect(buildEmailSort(STARRED_FIRST)).toEqual([
|
||||
{ property: 'hasKeyword', keyword: '$flagged', isAscending: false },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
// Unread first = $seen absent first = $seen ascending.
|
||||
expect(buildEmailSort(UNREAD_FIRST)).toEqual([
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('flips only the keyword comparators for an inverted server (Stalwart 0.16.8)', () => {
|
||||
expect(buildEmailSort(UNREAD_FIRST, { polarity: 'inverted' })).toEqual([
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
expect(buildEmailSort([{ criterion: 'size', direction: 'asc' }], { polarity: 'inverted' })).toEqual([
|
||||
{ property: 'size', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps pinned-first ahead of the configured order', () => {
|
||||
expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true })).toEqual([
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
||||
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
expect(buildEmailSort([], { pinnedFirst: true, polarity: 'inverted' })[0]).toEqual(
|
||||
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('drops keyword comparators when the server cannot sort on them', () => {
|
||||
expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true, keywordSortSupported: false })).toEqual([
|
||||
{ property: 'receivedAt', isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not add a second receivedAt comparator when the user ordered by date', () => {
|
||||
expect(buildEmailSort([
|
||||
{ criterion: 'from', direction: 'asc' },
|
||||
{ criterion: 'receivedAt', direction: 'asc' },
|
||||
])).toEqual([
|
||||
{ property: 'from', isAscending: true },
|
||||
{ property: 'receivedAt', isAscending: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareEmails', () => {
|
||||
const unreadOld = makeEmail({ id: 'unread-old', receivedAt: '2026-01-01T00:00:00Z' });
|
||||
const unreadNew = makeEmail({ id: 'unread-new', receivedAt: '2026-01-03T00:00:00Z' });
|
||||
const readNewest = makeEmail({ id: 'read-newest', receivedAt: '2026-01-05T00:00:00Z', keywords: { $seen: true } });
|
||||
const readMid = makeEmail({ id: 'read-mid', receivedAt: '2026-01-02T00:00:00Z', keywords: { $seen: true } });
|
||||
|
||||
it('is newest-first with no levels', () => {
|
||||
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([]));
|
||||
expect(sorted.map(e => e.id)).toEqual(['read-newest', 'unread-new', 'read-mid', 'unread-old']);
|
||||
});
|
||||
|
||||
it('puts unread first, each group newest first', () => {
|
||||
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails(UNREAD_FIRST));
|
||||
expect(sorted.map(e => e.id)).toEqual(['unread-new', 'unread-old', 'read-newest', 'read-mid']);
|
||||
});
|
||||
|
||||
it('honours "read first"', () => {
|
||||
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([{ criterion: 'unread', direction: 'asc' }]));
|
||||
expect(sorted.map(e => e.id)).toEqual(['read-newest', 'read-mid', 'unread-new', 'unread-old']);
|
||||
});
|
||||
|
||||
it('keeps pinned mail on top of any order', () => {
|
||||
const pinnedRead = makeEmail({ id: 'pinned', receivedAt: '2025-01-01T00:00:00Z', keywords: { $seen: true, $pinned: true } });
|
||||
const sorted = [unreadNew, pinnedRead, readNewest].sort(compareEmails(UNREAD_FIRST, { pinnedFirst: true }));
|
||||
expect(sorted.map(e => e.id)).toEqual(['pinned', 'unread-new', 'read-newest']);
|
||||
});
|
||||
|
||||
it('sorts by starred, tag, sender, subject and size', () => {
|
||||
const a = makeEmail({ id: 'a', size: 10, from: [{ name: 'Zoe', email: 'z@x' }], subject: 'Re: beta', keywords: { '$label:red': true } });
|
||||
const b = makeEmail({ id: 'b', size: 30, from: [{ name: '', email: 'alpha@x' }], subject: 'Fwd: alpha', keywords: { $flagged: true } });
|
||||
const c = makeEmail({ id: 'c', size: 20, from: [{ name: 'Mia', email: 'm@x' }], subject: 'gamma' });
|
||||
|
||||
expect([a, b, c].sort(compareEmails(STARRED_FIRST)).map(e => e.id)).toEqual(['b', 'a', 'c']);
|
||||
expect([a, b, c].sort(compareEmails([{ criterion: 'tag', direction: 'desc', tagId: 'red' }])).map(e => e.id)).toEqual(['a', 'b', 'c']);
|
||||
expect([a, b, c].sort(compareEmails([{ criterion: 'from', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']);
|
||||
expect([a, b, c].sort(compareEmails([{ criterion: 'subject', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'a', 'c']);
|
||||
expect([a, b, c].sort(compareEmails([{ criterion: 'size', direction: 'desc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('applies levels in priority order', () => {
|
||||
const starredOld = makeEmail({ id: 'starred-old', receivedAt: '2026-01-01T00:00:00Z', keywords: { $flagged: true, $seen: true } });
|
||||
const sorted = [readNewest, starredOld, unreadNew].sort(compareEmails([
|
||||
{ criterion: 'unread', direction: 'desc' },
|
||||
{ criterion: 'starred', direction: 'desc' },
|
||||
]));
|
||||
expect(sorted.map(e => e.id)).toEqual(['unread-new', 'starred-old', 'read-newest']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderForMailbox', () => {
|
||||
it('applies to the Inbox only by default', () => {
|
||||
expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'inbox')).toEqual(UNREAD_FIRST);
|
||||
expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'archive')).toEqual([]);
|
||||
expect(orderForMailbox(UNREAD_FIRST, 'inbox', null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies everywhere under the all-folders scope', () => {
|
||||
expect(orderForMailbox(UNREAD_FIRST, 'all', 'archive')).toEqual(UNREAD_FIRST);
|
||||
expect(orderForMailbox(UNREAD_FIRST, 'all', undefined)).toEqual(UNREAD_FIRST);
|
||||
expect(orderForMailbox([], 'all', 'inbox')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presets', () => {
|
||||
it('round-trips the simple presets', () => {
|
||||
expect(detectPreset(presetLevels('chronological'))).toBe('chronological');
|
||||
expect(detectPreset(presetLevels('unread_first'))).toBe('unread_first');
|
||||
expect(detectPreset(presetLevels('starred_first'))).toBe('starred_first');
|
||||
expect(detectPreset(presetLevels('tagged_first', 'red'))).toBe('tagged_first');
|
||||
expect(presetLevels('tagged_first')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an explicit trailing newest-first level as part of the preset', () => {
|
||||
expect(detectPreset([...UNREAD_FIRST, { criterion: 'receivedAt', direction: 'desc' }])).toBe('unread_first');
|
||||
expect(isDefaultOrder([{ criterion: 'receivedAt', direction: 'desc' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('reports anything else as custom', () => {
|
||||
expect(detectPreset([{ criterion: 'unread', direction: 'asc' }])).toBe('custom');
|
||||
expect(detectPreset([...UNREAD_FIRST, ...STARRED_FIRST])).toBe('custom');
|
||||
expect(detectPreset([{ criterion: 'size', direction: 'desc' }])).toBe('custom');
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,36 @@ describe('sortThreadGroups', () => {
|
||||
expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']);
|
||||
});
|
||||
|
||||
it('mirrors a configured list order instead of re-sorting by date (#718)', () => {
|
||||
// makeEmail defaults to $seen, so the unread group needs bare keywords.
|
||||
const unreadOld = { ...makeGroup('unread-old', '2024-01-01T00:00:00Z'), hasUnread: true };
|
||||
unreadOld.emails = [makeEmail({ id: 'u', receivedAt: '2024-01-01T00:00:00Z', keywords: {} })];
|
||||
const readNew = makeGroup('read-new', '2024-06-01T00:00:00Z');
|
||||
const sorted = sortThreadGroups([readNew, unreadOld], [{ criterion: 'unread', direction: 'desc' }]);
|
||||
expect(sorted.map(g => g.threadId)).toEqual(['unread-old', 'read-new']);
|
||||
});
|
||||
|
||||
it('places a thread where its best-ranked email sorts, not its latest', () => {
|
||||
// An old unread reply keeps the thread in the unread block even though
|
||||
// the newest email in it has been read.
|
||||
const mixed = makeGroup('mixed', '2024-06-01T00:00:00Z');
|
||||
mixed.emails = [
|
||||
makeEmail({ id: 'newest-read', receivedAt: '2024-06-01T00:00:00Z', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'old-unread', receivedAt: '2024-02-01T00:00:00Z', keywords: {} }),
|
||||
];
|
||||
const readNewer = makeGroup('read-newer', '2024-07-01T00:00:00Z');
|
||||
const sorted = sortThreadGroups([readNewer, mixed], [{ criterion: 'unread', direction: 'desc' }]);
|
||||
expect(sorted.map(g => g.threadId)).toEqual(['mixed', 'read-newer']);
|
||||
});
|
||||
|
||||
it('keeps pinned threads on top of a configured order', () => {
|
||||
const pinnedRead = makeGroup('pinned-read', '2024-01-01T00:00:00Z', true);
|
||||
const unread = makeGroup('unread', '2024-06-01T00:00:00Z');
|
||||
unread.emails = [makeEmail({ id: 'u', receivedAt: '2024-06-01T00:00:00Z', keywords: {} })];
|
||||
const sorted = sortThreadGroups([unread, pinnedRead], [{ criterion: 'unread', direction: 'desc' }]);
|
||||
expect(sorted.map(g => g.threadId)).toEqual(['pinned-read', 'unread']);
|
||||
});
|
||||
|
||||
it('detects hasPinned from the $pinned keyword', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
|
||||
+10
-6
@@ -3,6 +3,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
import { generateDemoId } from './demo-utils';
|
||||
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
|
||||
|
||||
/**
|
||||
* In-memory JMAP client for demo mode.
|
||||
@@ -178,7 +179,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
let filtered = this.data.emails;
|
||||
if (mailboxId) {
|
||||
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||
@@ -189,11 +190,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (extraFilter) {
|
||||
filtered = filtered.filter(e => this.matchesFilter(e, extraFilter));
|
||||
}
|
||||
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
||||
filtered.sort((a, b) =>
|
||||
pinRank(b) - pinRank(a) ||
|
||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
// Same order the JMAP client asks the server for (pinned first, then the
|
||||
// configured list order, then newest first).
|
||||
filtered.sort(compareEmails(order, { pinnedFirst }));
|
||||
const total = filtered.length;
|
||||
const emails = filtered.slice(position, position + limit);
|
||||
return { emails, hasMore: position + limit < total, total };
|
||||
@@ -212,6 +211,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
getEmailQuerySortOptions(_accountId?: string): string[] | null {
|
||||
// The demo sorts client-side and supports every criterion.
|
||||
return null;
|
||||
}
|
||||
|
||||
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||||
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { SortLevel } from "@/lib/message-list-order";
|
||||
|
||||
/** What `migrateKeyword` managed to do. */
|
||||
export interface KeywordMigration {
|
||||
@@ -112,7 +113,16 @@ export interface IJMAPClient {
|
||||
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
|
||||
// `extraFilter` is an arbitrary JMAP FilterCondition/FilterOperator ANDed
|
||||
// into the view - used by the message-list category tabs (search-based).
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
// `order` is the user's configured message-list order (#718), applied
|
||||
// server-side after pinned-first; see lib/message-list-order.ts.
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
/**
|
||||
* Sort properties the account's server advertises for Email/query
|
||||
* (`emailQuerySortOptions` in the mail capability, RFC 8621 §1.3), or null
|
||||
* when the server does not say. Used to grey out keyword-based ordering
|
||||
* criteria the server cannot honour.
|
||||
*/
|
||||
getEmailQuerySortOptions?(accountId?: string): string[] | null;
|
||||
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
|
||||
|
||||
+135
-17
@@ -13,6 +13,7 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
import { decodeFileNodeName } from "./filenode-name";
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||||
|
||||
// Names of nodes created over WebDAV come back percent-encoded from
|
||||
// FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so
|
||||
@@ -202,6 +203,10 @@ const EMAIL_LIST_PROPERTIES = [
|
||||
*/
|
||||
export const DEFAULT_KEYWORD_SCAN_LIMIT = 25000;
|
||||
|
||||
// How long an inconclusive keyword-sort polarity probe (#718) is trusted
|
||||
// before the next folder fetch tries again.
|
||||
const KEYWORD_SORT_PROBE_RETRY_MS = 5 * 60 * 1000;
|
||||
|
||||
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||||
// includeInAvailability, and the default-alerts properties. Without an explicit
|
||||
// `properties` list the share indicator and share dialog can't see existing
|
||||
@@ -658,6 +663,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
private rateLimitedUntil: number = 0;
|
||||
private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null;
|
||||
private rateLimitTimeout: NodeJS.Timeout | null = null;
|
||||
// Per-account result of the keyword sort polarity probe (#718). `null` means
|
||||
// the probe was inconclusive (every message read, or unread) and is retried
|
||||
// after KEYWORD_SORT_PROBE_RETRY_MS; a determined value lives for the session.
|
||||
private keywordSortPolarity = new Map<string, { value: KeywordSortPolarity | null; at: number }>();
|
||||
private keywordSortProbes = new Map<string, Promise<KeywordSortPolarity>>();
|
||||
// Accounts whose server refused a hasKeyword comparator with unsupportedSort.
|
||||
private keywordSortUnsupported = new Set<string>();
|
||||
private lastRateLimitNoticeAt: number = 0;
|
||||
// Serialises the first calendar/contacts request per account so Stalwart's
|
||||
// lazy default-calendar/address-book creation can't run twice (#907).
|
||||
@@ -1326,7 +1338,108 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
/**
|
||||
* Sort properties the server advertises for Email/query on this account
|
||||
* (RFC 8621 §1.3 `emailQuerySortOptions`), or null when it doesn't say.
|
||||
*/
|
||||
getEmailQuerySortOptions(accountId?: string): string[] | null {
|
||||
const id = accountId || this.accountId;
|
||||
const caps = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:mail"] as
|
||||
| { emailQuerySortOptions?: unknown }
|
||||
| undefined;
|
||||
const options = caps?.emailQuerySortOptions;
|
||||
return Array.isArray(options) ? options.filter((o): o is string => typeof o === "string") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether keyword comparators (hasKeyword) can be sent to this account's
|
||||
* server: not when it advertises a sort-option list without them, and not
|
||||
* after it has refused one with `unsupportedSort`. Unknown counts as yes.
|
||||
*/
|
||||
private keywordSortSupported(accountId: string): boolean {
|
||||
if (this.keywordSortUnsupported.has(accountId)) return false;
|
||||
const options = this.getEmailQuerySortOptions(accountId);
|
||||
return options === null || options.includes("hasKeyword");
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines, once per account and session, which way the server reads
|
||||
* `isAscending` on hasKeyword comparators (see KeywordSortPolarity in
|
||||
* lib/message-list-order.ts). Two one-item queries with opposite
|
||||
* directions: if they return different messages both groups exist and the
|
||||
* keywords of the ascending winner settle the polarity; if they return the
|
||||
* same message the mailbox is homogeneous and the probe is inconclusive.
|
||||
* Any failure falls back to the RFC reading without caching a verdict.
|
||||
*/
|
||||
private resolveKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity> {
|
||||
const cached = this.keywordSortPolarity.get(accountId);
|
||||
if (cached && (cached.value !== null || Date.now() - cached.at < KEYWORD_SORT_PROBE_RETRY_MS)) {
|
||||
return Promise.resolve(cached.value ?? "rfc");
|
||||
}
|
||||
const pending = this.keywordSortProbes.get(accountId);
|
||||
if (pending) return pending;
|
||||
const probe = this.probeKeywordSortPolarity(accountId)
|
||||
.then((value) => {
|
||||
this.keywordSortPolarity.set(accountId, { value, at: Date.now() });
|
||||
return value ?? "rfc";
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.warn("jmap", "Keyword sort polarity probe failed:", error);
|
||||
this.keywordSortPolarity.set(accountId, { value: null, at: Date.now() });
|
||||
return "rfc" as const;
|
||||
})
|
||||
.finally(() => {
|
||||
this.keywordSortProbes.delete(accountId);
|
||||
});
|
||||
this.keywordSortProbes.set(accountId, probe);
|
||||
return probe;
|
||||
}
|
||||
|
||||
private async probeKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity | null> {
|
||||
const sortFor = (isAscending: boolean) => [
|
||||
{ property: "hasKeyword", keyword: "$seen", isAscending },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
];
|
||||
const response = await this.request([
|
||||
["Email/query", { accountId, sort: sortFor(true), limit: 1 }, "asc"],
|
||||
["Email/query", { accountId, sort: sortFor(false), limit: 1 }, "desc"],
|
||||
["Email/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "asc", name: "Email/query", path: "/ids" },
|
||||
properties: ["id", "keywords"],
|
||||
}, "get"],
|
||||
]);
|
||||
const [ascMethod, ascResult] = response.methodResponses?.[0] ?? [];
|
||||
if (ascMethod === "error") {
|
||||
if ((ascResult as { type?: string })?.type === "unsupportedSort") {
|
||||
this.keywordSortUnsupported.add(accountId);
|
||||
return "rfc";
|
||||
}
|
||||
throw new Error((ascResult as { description?: string })?.description || "Email/query failed");
|
||||
}
|
||||
const ascIds = ((ascResult as { ids?: string[] })?.ids) ?? [];
|
||||
const descIds = ((response.methodResponses?.[1]?.[1] as { ids?: string[] })?.ids) ?? [];
|
||||
if (ascIds.length === 0 || descIds.length === 0 || ascIds[0] === descIds[0]) return null;
|
||||
const first = ((response.methodResponses?.[2]?.[1] as { list?: Email[] })?.list ?? [])[0];
|
||||
if (!first || first.id !== ascIds[0]) return null;
|
||||
// Ascending put a $seen message first: the server sorts has-keyword first
|
||||
// on isAscending: true, the inverse of the RFC's false < true reading.
|
||||
return first.keywords?.$seen ? "inverted" : "rfc";
|
||||
}
|
||||
|
||||
/** The Email/query sort for a folder view, with the server's polarity applied. */
|
||||
private async buildListSort(accountId: string, pinnedFirst: boolean, order: SortLevel[]): Promise<{ sort: ReturnType<typeof buildEmailSort>; keywordSortSupported: boolean }> {
|
||||
if (!hasKeywordLevels(order, pinnedFirst)) {
|
||||
return { sort: buildEmailSort(order), keywordSortSupported: true };
|
||||
}
|
||||
if (!this.keywordSortSupported(accountId)) {
|
||||
return { sort: buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }), keywordSortSupported: false };
|
||||
}
|
||||
const polarity = await this.resolveKeywordSortPolarity(accountId);
|
||||
return { sort: buildEmailSort(order, { pinnedFirst, polarity }), keywordSortSupported: true };
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
||||
@@ -1347,16 +1460,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
],
|
||||
}
|
||||
: simple;
|
||||
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
|
||||
// every page of a view must use the same sort or pagination tears.
|
||||
const sort = pinnedFirst
|
||||
? [
|
||||
{ property: "hasKeyword", keyword: "$pinned", isAscending: false },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
]
|
||||
: [{ property: "receivedAt", isAscending: false }];
|
||||
|
||||
const response = await this.request([
|
||||
// Pinned-first and the configured list order (#718) use the hasKeyword
|
||||
// sort comparator (RFC 8621 §4.4.2); every page of a view must use the
|
||||
// same sort or pagination tears, so the sort is built from settings the
|
||||
// same way on the first page, load-more and the push refresh.
|
||||
const built = await this.buildListSort(targetAccountId, pinnedFirst === true, order);
|
||||
const query = (sort: ReturnType<typeof buildEmailSort>) => this.request([
|
||||
["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter,
|
||||
@@ -1372,6 +1481,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
let response = await query(built.sort);
|
||||
// A server that advertises nothing but refuses hasKeyword fails the whole
|
||||
// query. Remember that and retry once without the keyword comparators
|
||||
// rather than showing an empty folder.
|
||||
if (
|
||||
built.keywordSortSupported &&
|
||||
response.methodResponses?.[0]?.[0] === "error" &&
|
||||
(response.methodResponses[0][1] as { type?: string })?.type === "unsupportedSort"
|
||||
) {
|
||||
this.keywordSortUnsupported.add(targetAccountId);
|
||||
response = await query(buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }));
|
||||
}
|
||||
|
||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||
const getResponse = response.methodResponses?.[1]?.[1];
|
||||
|
||||
@@ -1379,12 +1501,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
const emails = (getResponse.list || []) as Email[];
|
||||
// Sort client-side as safety net - some servers may not honour
|
||||
// the query sort for large mailboxes without additional filters.
|
||||
// Must mirror the query sort, or it would undo the pinned-first order.
|
||||
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
||||
emails.sort((a: Email, b: Email) =>
|
||||
pinRank(b) - pinRank(a) ||
|
||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
// Must mirror the query sort, or it would undo the configured order.
|
||||
emails.sort(compareEmails(order, { pinnedFirst }));
|
||||
const total = queryResponse?.total || 0;
|
||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { Email } from './jmap/types';
|
||||
|
||||
/**
|
||||
* Configurable message-list ordering (#718).
|
||||
*
|
||||
* An ordering is a prioritised list of up to MAX_SORT_LEVELS sort levels. Each
|
||||
* level is a {criterion, direction} pair; the list maps 1:1 onto the JMAP
|
||||
* Email/query `sort` array (RFC 8621 §4.4.2), so the server orders the whole
|
||||
* mailbox and pagination stays consistent. This module owns every piece of
|
||||
* that mapping so the UI only ever deals in semantic choices ("unread first",
|
||||
* "newest first") and the isAscending polarity is decided in exactly one place.
|
||||
*/
|
||||
|
||||
export type SortCriterion =
|
||||
| 'unread'
|
||||
| 'starred'
|
||||
| 'tag'
|
||||
| 'receivedAt'
|
||||
| 'sentAt'
|
||||
| 'from'
|
||||
| 'subject'
|
||||
| 'size';
|
||||
|
||||
/**
|
||||
* Direction of the criterion's *value*: `desc` puts the higher / true value
|
||||
* first. For the boolean criteria the value is "is unread" / "is starred" /
|
||||
* "has the tag", so `desc` means "unread first", "starred first", "tagged
|
||||
* first". For dates `desc` is newest first; for size largest first; for
|
||||
* sender / subject `asc` is A→Z.
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortLevel {
|
||||
criterion: SortCriterion;
|
||||
direction: SortDirection;
|
||||
/** Only for criterion 'tag': the KeywordDefinition id (JMAP keyword $label:<id>). */
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
/** Which folders the configured order applies to. */
|
||||
export type MessageListOrderScope = 'inbox' | 'all';
|
||||
|
||||
/**
|
||||
* How the server interprets `isAscending` on the keyword comparators.
|
||||
*
|
||||
* RFC 8621 §4.4.2: hasKeyword is a boolean value (false < true), so
|
||||
* `isAscending: false` puts messages that HAVE the keyword first - the RFC's
|
||||
* own `$flagged` example "would sort Emails in flagged Threads first" with
|
||||
* isAscending false. Stalwart (verified on 0.16.8, reported upstream) does the
|
||||
* opposite: `isAscending: true` puts has-keyword first. The client probes the
|
||||
* server once per account (see JMAPClient.resolveKeywordSortPolarity) and
|
||||
* feeds the result in here, so a fixed server flips back automatically.
|
||||
*/
|
||||
export type KeywordSortPolarity = 'rfc' | 'inverted';
|
||||
|
||||
export const MAX_SORT_LEVELS = 3;
|
||||
|
||||
export const SORT_CRITERIA: readonly SortCriterion[] = [
|
||||
'unread',
|
||||
'starred',
|
||||
'tag',
|
||||
'receivedAt',
|
||||
'sentAt',
|
||||
'from',
|
||||
'subject',
|
||||
'size',
|
||||
];
|
||||
|
||||
const KEYWORD_CRITERIA: ReadonlySet<SortCriterion> = new Set(['unread', 'starred', 'tag']);
|
||||
|
||||
export function isKeywordCriterion(criterion: SortCriterion): boolean {
|
||||
return KEYWORD_CRITERIA.has(criterion);
|
||||
}
|
||||
|
||||
/** The direction a freshly added level gets: the one people mean by default. */
|
||||
export function defaultDirection(criterion: SortCriterion): SortDirection {
|
||||
return criterion === 'from' || criterion === 'subject' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
export interface JMAPEmailComparator {
|
||||
property: string;
|
||||
isAscending: boolean;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPARATOR: JMAPEmailComparator = { property: 'receivedAt', isAscending: false };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces an untrusted value (persisted state, a synced settings blob, a file
|
||||
* import) into a valid level list: unknown criteria and directions are dropped,
|
||||
* a tag level without a tag id is dropped, duplicate criteria are dropped (a
|
||||
* second "unread" level can never change the order), and the list is capped.
|
||||
*/
|
||||
export function sanitizeSortLevels(value: unknown): SortLevel[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: SortLevel[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of value) {
|
||||
if (!isRecord(raw)) continue;
|
||||
const criterion = raw.criterion;
|
||||
if (typeof criterion !== 'string' || !SORT_CRITERIA.includes(criterion as SortCriterion)) continue;
|
||||
const direction: SortDirection = raw.direction === 'asc' || raw.direction === 'desc'
|
||||
? raw.direction
|
||||
: defaultDirection(criterion as SortCriterion);
|
||||
const level: SortLevel = { criterion: criterion as SortCriterion, direction };
|
||||
if (criterion === 'tag') {
|
||||
if (typeof raw.tagId !== 'string' || raw.tagId.length === 0) continue;
|
||||
level.tagId = raw.tagId;
|
||||
}
|
||||
const key = criterion === 'tag' ? `tag:${level.tagId}` : criterion;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(level);
|
||||
if (out.length >= MAX_SORT_LEVELS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The JMAP keyword a boolean level sorts on, or null for value criteria. */
|
||||
export function levelKeyword(level: SortLevel): string | null {
|
||||
switch (level.criterion) {
|
||||
case 'unread': return '$seen';
|
||||
case 'starred': return '$flagged';
|
||||
case 'tag': return level.tagId ? `$label:${level.tagId}` : null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether messages that HAVE the level's keyword should come first. "Unread
|
||||
* first" is the absence of $seen first, so it inverts relative to the others.
|
||||
*/
|
||||
export function keywordFirst(level: SortLevel): boolean {
|
||||
const wantsTrueFirst = level.direction === 'desc';
|
||||
return level.criterion === 'unread' ? !wantsTrueFirst : wantsTrueFirst;
|
||||
}
|
||||
|
||||
/** True when the order (or pinned-first) needs a keyword comparator server-side. */
|
||||
export function hasKeywordLevels(levels: SortLevel[], pinnedFirst = false): boolean {
|
||||
return pinnedFirst || levels.some(l => levelKeyword(l) !== null);
|
||||
}
|
||||
|
||||
/** A "chronological" order: nothing configured, or only newest-first. */
|
||||
export function isDefaultOrder(levels: SortLevel[]): boolean {
|
||||
return levels.length === 0
|
||||
|| (levels.length === 1 && levels[0].criterion === 'receivedAt' && levels[0].direction === 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Email/query `sort` array for a level list.
|
||||
*
|
||||
* - `pinnedFirst` prepends the existing $pinned-on-top comparator.
|
||||
* - `polarity` is the server's keyword comparator polarity (see
|
||||
* KeywordSortPolarity); defaults to the RFC reading.
|
||||
* - `keywordSortSupported: false` drops every keyword comparator (the server
|
||||
* advertises no hasKeyword support or refused it with unsupportedSort); the
|
||||
* remaining value comparators still apply.
|
||||
* - A trailing receivedAt-desc tie-breaker is always present so the order is
|
||||
* total and stable across pages.
|
||||
*/
|
||||
export function buildEmailSort(
|
||||
levels: SortLevel[],
|
||||
opts: { pinnedFirst?: boolean; polarity?: KeywordSortPolarity; keywordSortSupported?: boolean } = {},
|
||||
): JMAPEmailComparator[] {
|
||||
const polarity = opts.polarity ?? 'rfc';
|
||||
const keywordsOk = opts.keywordSortSupported !== false;
|
||||
const keywordComparator = (keyword: string, first: boolean): JMAPEmailComparator => ({
|
||||
property: 'hasKeyword',
|
||||
keyword,
|
||||
// RFC: false < true, so has-keyword first is descending. Inverted servers
|
||||
// read it the other way round.
|
||||
isAscending: polarity === 'rfc' ? !first : first,
|
||||
});
|
||||
|
||||
const sort: JMAPEmailComparator[] = [];
|
||||
if (opts.pinnedFirst && keywordsOk) {
|
||||
sort.push(keywordComparator('$pinned', true));
|
||||
}
|
||||
let hasDateLevel = false;
|
||||
for (const level of levels) {
|
||||
const keyword = levelKeyword(level);
|
||||
if (keyword) {
|
||||
if (keywordsOk) sort.push(keywordComparator(keyword, keywordFirst(level)));
|
||||
continue;
|
||||
}
|
||||
if (level.criterion === 'receivedAt') hasDateLevel = true;
|
||||
sort.push({ property: level.criterion, isAscending: level.direction === 'asc' });
|
||||
}
|
||||
if (!hasDateLevel) sort.push({ ...DEFAULT_COMPARATOR });
|
||||
return sort;
|
||||
}
|
||||
|
||||
/** RFC 8621 "from"/"to" sort value: the first address's name, else its email. */
|
||||
function addressSortValue(email: Email): string {
|
||||
const first = email.from?.[0];
|
||||
if (!first) return '';
|
||||
return (first.name || first.email || '').toLowerCase();
|
||||
}
|
||||
|
||||
/** RFC 5256 §2.1 base subject, approximated: strip reply/forward prefixes. */
|
||||
function baseSubject(subject: string | undefined): string {
|
||||
return (subject ?? '')
|
||||
.replace(/^\s*(?:(?:re|fwd?|aw|wg|sv|vs|tr)\s*(?:\[\d+\])?\s*:\s*)+/i, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function time(value: string | undefined): number {
|
||||
if (!value) return 0;
|
||||
const t = new Date(value).getTime();
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
function levelValue(level: SortLevel, email: Email): number | string {
|
||||
switch (level.criterion) {
|
||||
case 'unread': return email.keywords?.$seen ? 0 : 1;
|
||||
case 'starred': return email.keywords?.$flagged ? 1 : 0;
|
||||
case 'tag': return level.tagId && email.keywords?.[`$label:${level.tagId}`] ? 1 : 0;
|
||||
case 'receivedAt': return time(email.receivedAt);
|
||||
case 'sentAt': return time(email.sentAt || email.receivedAt);
|
||||
case 'size': return email.size ?? 0;
|
||||
case 'from': return addressSortValue(email);
|
||||
case 'subject': return baseSubject(email.subject);
|
||||
}
|
||||
}
|
||||
|
||||
function compareValues(a: number | string, b: number | string): number {
|
||||
if (typeof a === 'string' || typeof b === 'string') {
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
return a - b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror of `buildEmailSort` for the same level list. Used as the
|
||||
* within-page safety net (some servers ignore the sort on unfiltered queries),
|
||||
* for merging per-account pages in the unified view, for the demo client, and
|
||||
* to keep thread grouping from undoing the server order.
|
||||
*/
|
||||
export function compareEmails(
|
||||
levels: SortLevel[],
|
||||
opts: { pinnedFirst?: boolean } = {},
|
||||
): (a: Email, b: Email) => number {
|
||||
const hasDateLevel = levels.some(l => l.criterion === 'receivedAt');
|
||||
return (a, b) => {
|
||||
if (opts.pinnedFirst) {
|
||||
const pa = a.keywords?.['$pinned'] ? 1 : 0;
|
||||
const pb = b.keywords?.['$pinned'] ? 1 : 0;
|
||||
if (pa !== pb) return pb - pa;
|
||||
}
|
||||
for (const level of levels) {
|
||||
const diff = compareValues(levelValue(level, a), levelValue(level, b));
|
||||
if (diff !== 0) return level.direction === 'asc' ? diff : -diff;
|
||||
}
|
||||
if (!hasDateLevel) {
|
||||
const diff = time(b.receivedAt) - time(a.receivedAt);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The order to use for a given folder: the configured levels when they apply
|
||||
* to every folder, or to the Inbox when scoped to it; chronological otherwise.
|
||||
*/
|
||||
export function orderForMailbox(
|
||||
levels: SortLevel[],
|
||||
scope: MessageListOrderScope,
|
||||
role: string | null | undefined,
|
||||
): SortLevel[] {
|
||||
if (levels.length === 0) return [];
|
||||
if (scope === 'all' || role === 'inbox') return levels;
|
||||
return [];
|
||||
}
|
||||
|
||||
export type OrderPreset = 'chronological' | 'unread_first' | 'starred_first' | 'tagged_first' | 'custom';
|
||||
|
||||
export const ORDER_PRESETS: readonly OrderPreset[] = ['chronological', 'unread_first', 'starred_first', 'tagged_first'];
|
||||
|
||||
/** Levels for a simple preset; `tagId` is required for `tagged_first`. */
|
||||
export function presetLevels(preset: OrderPreset, tagId?: string): SortLevel[] {
|
||||
switch (preset) {
|
||||
case 'unread_first': return [{ criterion: 'unread', direction: 'desc' }];
|
||||
case 'starred_first': return [{ criterion: 'starred', direction: 'desc' }];
|
||||
case 'tagged_first': return tagId ? [{ criterion: 'tag', direction: 'desc', tagId }] : [];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The preset a level list corresponds to, or 'custom' for anything else. */
|
||||
export function detectPreset(levels: SortLevel[]): OrderPreset {
|
||||
// A trailing newest-first level is implicit and doesn't make an order custom.
|
||||
const core = levels.length > 1
|
||||
&& levels[levels.length - 1].criterion === 'receivedAt'
|
||||
&& levels[levels.length - 1].direction === 'desc'
|
||||
? levels.slice(0, -1)
|
||||
: levels;
|
||||
if (isDefaultOrder(core)) return 'chronological';
|
||||
if (core.length !== 1 || core[0].direction !== 'desc') return 'custom';
|
||||
switch (core[0].criterion) {
|
||||
case 'unread': return 'unread_first';
|
||||
case 'starred': return 'starred_first';
|
||||
case 'tag': return 'tagged_first';
|
||||
default: return 'custom';
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -1,4 +1,5 @@
|
||||
import type { Email, ThreadGroup } from "./jmap/types";
|
||||
import { compareEmails, type SortLevel } from "./message-list-order";
|
||||
|
||||
/**
|
||||
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
|
||||
@@ -71,15 +72,26 @@ export function groupEmailsByThread(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts thread groups by their latest email's receivedAt date (newest first).
|
||||
* Sorts thread groups to mirror the order the email list was fetched in.
|
||||
* Threads containing a pinned email ($pinned keyword) stay on top, mirroring
|
||||
* the server-side pinned-first sort of the email list.
|
||||
* the server-side pinned-first sort of the email list. Below that, each thread
|
||||
* takes the position of whichever of its emails sorts first under `order`
|
||||
* (RFC 8621 §4.4.3 thread collapsing semantics) - with the default order that
|
||||
* is the latest email's receivedAt date, newest first.
|
||||
*/
|
||||
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
|
||||
export function sortThreadGroups(groups: ThreadGroup[], order: SortLevel[] = []): ThreadGroup[] {
|
||||
const compare = compareEmails(order);
|
||||
const representative = new Map<ThreadGroup, Email>();
|
||||
for (const group of groups) {
|
||||
representative.set(
|
||||
group,
|
||||
group.emails.reduce((best, email) => (compare(email, best) < 0 ? email : best), group.emails[0] ?? group.latestEmail),
|
||||
);
|
||||
}
|
||||
return [...groups].sort(
|
||||
(a, b) =>
|
||||
(b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) ||
|
||||
new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
|
||||
compare(representative.get(a)!, representative.get(b)!)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-12
@@ -1,6 +1,7 @@
|
||||
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
|
||||
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
|
||||
|
||||
export interface UnifiedAccountClient {
|
||||
// Display reference (avatar color / label). For personal entries this is the
|
||||
@@ -91,6 +92,7 @@ export async function fetchUnifiedEmails(
|
||||
role: UnifiedMailboxRole,
|
||||
limit: number,
|
||||
position: number,
|
||||
order: SortLevel[] = [],
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
@@ -108,12 +110,11 @@ export async function fetchUnifiedEmails(
|
||||
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
try {
|
||||
const result = await account.client.getEmails(
|
||||
jmapMailboxId,
|
||||
jmapAccountId,
|
||||
limit,
|
||||
position,
|
||||
);
|
||||
// The configured list order (#718) rides along only when set, so the
|
||||
// default call shape stays the four-argument one.
|
||||
const result = order.length > 0
|
||||
? await account.client.getEmails(jmapMailboxId, jmapAccountId, limit, position, undefined, undefined, undefined, order)
|
||||
: await account.client.getEmails(jmapMailboxId, jmapAccountId, limit, position);
|
||||
return { account, result };
|
||||
} catch (err) {
|
||||
errors.set(
|
||||
@@ -155,12 +156,9 @@ export async function fetchUnifiedEmails(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort merged emails by receivedAt descending.
|
||||
mergedEmails.sort((a, b) => {
|
||||
const dateA = new Date(a.receivedAt).getTime();
|
||||
const dateB = new Date(b.receivedAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
// Merge the per-account pages under the same order each server applied
|
||||
// (receivedAt descending by default).
|
||||
mergedEmails.sort(compareEmails(order));
|
||||
|
||||
return {
|
||||
emails: mergedEmails,
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "متقدم"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "ترتيب قائمة الرسائل",
|
||||
"description": "كيفية ترتيب الرسائل في القائمة. يقوم الخادم بالترتيب، لذا يشمل المجلد بأكمله وليس الرسائل المحمّلة فقط.",
|
||||
"preset": {
|
||||
"chronological": "زمني (الأحدث أولاً)",
|
||||
"unread_first": "غير المقروءة أولاً",
|
||||
"starred_first": "المميزة بنجمة أولاً",
|
||||
"tagged_first": "الموسومة أولاً",
|
||||
"custom": "مخصص"
|
||||
},
|
||||
"tag_label": "الوسم",
|
||||
"advanced_show": "متقدم: تحرير مستويات الترتيب",
|
||||
"advanced_hide": "إخفاء الخيارات المتقدمة",
|
||||
"criterion_label": "معيار المستوى {n}",
|
||||
"direction_label": "اتجاه المستوى {n}",
|
||||
"criteria": {
|
||||
"unread": "غير مقروءة",
|
||||
"starred": "مميزة بنجمة",
|
||||
"tag": "الوسم",
|
||||
"receivedAt": "تاريخ الاستلام",
|
||||
"sentAt": "تاريخ الإرسال",
|
||||
"from": "المرسل",
|
||||
"subject": "الموضوع",
|
||||
"size": "الحجم"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "غير المقروءة أولاً",
|
||||
"read_first": "المقروءة أولاً",
|
||||
"starred_first": "المميزة بنجمة أولاً",
|
||||
"unstarred_first": "غير المميزة أولاً",
|
||||
"tagged_first": "الموسومة أولاً",
|
||||
"untagged_first": "غير الموسومة أولاً",
|
||||
"newest_first": "الأحدث أولاً",
|
||||
"oldest_first": "الأقدم أولاً",
|
||||
"a_to_z": "أ → ي",
|
||||
"z_to_a": "ي → أ",
|
||||
"largest_first": "الأكبر أولاً",
|
||||
"smallest_first": "الأصغر أولاً"
|
||||
},
|
||||
"add_level": "إضافة مستوى",
|
||||
"remove_level": "إزالة المستوى",
|
||||
"move_up": "نقل لأعلى",
|
||||
"move_down": "نقل لأسفل",
|
||||
"tiebreak_note": "حتى ثلاثة مستويات؛ تحسم المستويات اللاحقة حالات التعادل. يُطبَّق «الأحدث أولاً» دائمًا في النهاية.",
|
||||
"scope": {
|
||||
"label": "تطبيق على جميع المجلدات",
|
||||
"description": "عند الإيقاف، يُطبَّق الترتيب على صندوق الوارد فقط؛ وتبقى المجلدات الأخرى مرتبة زمنيًا."
|
||||
},
|
||||
"unsupported_note": "خادم البريد لديك لا يدعم الترتيب حسب حالة القراءة أو النجمة أو الوسوم. يتم تجاهل هذه المستويات وتبقى القائمة مرتبة زمنيًا."
|
||||
},
|
||||
"title": "المظهر",
|
||||
"description": "تخصيص شكل ومظهر البريد الإلكتروني على الويب",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Avançat"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ordre de la llista de missatges",
|
||||
"description": "Com s'ordenen els missatges a la llista. L'ordenació la fa el servidor, de manera que abasta tota la carpeta i no només els missatges carregats.",
|
||||
"preset": {
|
||||
"chronological": "Cronològic (més recents primer)",
|
||||
"unread_first": "No llegits primer",
|
||||
"starred_first": "Destacats primer",
|
||||
"tagged_first": "Etiquetats primer",
|
||||
"custom": "Personalitzat"
|
||||
},
|
||||
"tag_label": "Etiqueta",
|
||||
"advanced_show": "Avançat: edita els nivells d'ordenació",
|
||||
"advanced_hide": "Amaga les opcions avançades",
|
||||
"criterion_label": "Criteri del nivell {n}",
|
||||
"direction_label": "Direcció del nivell {n}",
|
||||
"criteria": {
|
||||
"unread": "No llegit",
|
||||
"starred": "Destacat",
|
||||
"tag": "Etiqueta",
|
||||
"receivedAt": "Data de recepció",
|
||||
"sentAt": "Data d'enviament",
|
||||
"from": "Remitent",
|
||||
"subject": "Assumpte",
|
||||
"size": "Mida"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "No llegits primer",
|
||||
"read_first": "Llegits primer",
|
||||
"starred_first": "Destacats primer",
|
||||
"unstarred_first": "No destacats primer",
|
||||
"tagged_first": "Etiquetats primer",
|
||||
"untagged_first": "Sense etiqueta primer",
|
||||
"newest_first": "Més recents primer",
|
||||
"oldest_first": "Més antics primer",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Més grans primer",
|
||||
"smallest_first": "Més petits primer"
|
||||
},
|
||||
"add_level": "Afegeix un nivell",
|
||||
"remove_level": "Elimina el nivell",
|
||||
"move_up": "Mou amunt",
|
||||
"move_down": "Mou avall",
|
||||
"tiebreak_note": "Fins a tres nivells; els nivells posteriors desfan els empats. «Més recents primer» s'aplica sempre al final.",
|
||||
"scope": {
|
||||
"label": "Aplica a totes les carpetes",
|
||||
"description": "Si està desactivat, l'ordre només s'aplica a la safata d'entrada; la resta de carpetes es mantenen cronològiques."
|
||||
},
|
||||
"unsupported_note": "El teu servidor de correu no admet ordenar per estat de lectura, destacats o etiquetes. Aquests nivells s'ignoren i la llista es manté cronològica."
|
||||
},
|
||||
"title": "Aparença",
|
||||
"description": "Personalitzeu l'aspecte del vostre correu web",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Pokročilé"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Pořadí seznamu zpráv",
|
||||
"description": "Jak jsou zprávy v seznamu seřazeny. Řazení provádí server, takže platí pro celou složku, ne jen pro načtené zprávy.",
|
||||
"preset": {
|
||||
"chronological": "Chronologicky (nejnovější první)",
|
||||
"unread_first": "Nepřečtené první",
|
||||
"starred_first": "S hvězdičkou první",
|
||||
"tagged_first": "Se štítkem první",
|
||||
"custom": "Vlastní"
|
||||
},
|
||||
"tag_label": "Štítek",
|
||||
"advanced_show": "Pokročilé: upravit úrovně řazení",
|
||||
"advanced_hide": "Skrýt pokročilé",
|
||||
"criterion_label": "Kritérium úrovně {n}",
|
||||
"direction_label": "Směr úrovně {n}",
|
||||
"criteria": {
|
||||
"unread": "Nepřečtené",
|
||||
"starred": "S hvězdičkou",
|
||||
"tag": "Štítek",
|
||||
"receivedAt": "Datum přijetí",
|
||||
"sentAt": "Datum odeslání",
|
||||
"from": "Odesílatel",
|
||||
"subject": "Předmět",
|
||||
"size": "Velikost"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Nepřečtené první",
|
||||
"read_first": "Přečtené první",
|
||||
"starred_first": "S hvězdičkou první",
|
||||
"unstarred_first": "Bez hvězdičky první",
|
||||
"tagged_first": "Se štítkem první",
|
||||
"untagged_first": "Bez štítku první",
|
||||
"newest_first": "Nejnovější první",
|
||||
"oldest_first": "Nejstarší první",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Největší první",
|
||||
"smallest_first": "Nejmenší první"
|
||||
},
|
||||
"add_level": "Přidat úroveň",
|
||||
"remove_level": "Odebrat úroveň",
|
||||
"move_up": "Posunout nahoru",
|
||||
"move_down": "Posunout dolů",
|
||||
"tiebreak_note": "Až tři úrovně; další úrovně rozhodují při shodě. „Nejnovější první“ se vždy uplatní jako poslední.",
|
||||
"scope": {
|
||||
"label": "Použít na všechny složky",
|
||||
"description": "Pokud je vypnuto, pořadí platí jen pro Doručenou poštu; ostatní složky zůstávají chronologické."
|
||||
},
|
||||
"unsupported_note": "Váš poštovní server nepodporuje řazení podle stavu přečtení, hvězdiček ani štítků. Tyto úrovně se ignorují a seznam zůstává chronologický."
|
||||
},
|
||||
"title": "Vzhled",
|
||||
"description": "Přizpůsobte si vzhled a chování vašeho webového e-mailu",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Avanceret"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Rækkefølge i beskedlisten",
|
||||
"description": "Hvordan beskeder sorteres i listen. Serveren sorterer, så det gælder hele mappen og ikke kun de indlæste beskeder.",
|
||||
"preset": {
|
||||
"chronological": "Kronologisk (nyeste først)",
|
||||
"unread_first": "Ulæste først",
|
||||
"starred_first": "Stjernemarkerede først",
|
||||
"tagged_first": "Med mærkat først",
|
||||
"custom": "Tilpasset"
|
||||
},
|
||||
"tag_label": "Mærkat",
|
||||
"advanced_show": "Avanceret: rediger sorteringsniveauer",
|
||||
"advanced_hide": "Skjul avanceret",
|
||||
"criterion_label": "Kriterium for niveau {n}",
|
||||
"direction_label": "Retning for niveau {n}",
|
||||
"criteria": {
|
||||
"unread": "Ulæst",
|
||||
"starred": "Stjernemarkeret",
|
||||
"tag": "Mærkat",
|
||||
"receivedAt": "Modtagelsesdato",
|
||||
"sentAt": "Afsendelsesdato",
|
||||
"from": "Afsender",
|
||||
"subject": "Emne",
|
||||
"size": "Størrelse"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Ulæste først",
|
||||
"read_first": "Læste først",
|
||||
"starred_first": "Stjernemarkerede først",
|
||||
"unstarred_first": "Uden stjerne først",
|
||||
"tagged_first": "Med mærkat først",
|
||||
"untagged_first": "Uden mærkat først",
|
||||
"newest_first": "Nyeste først",
|
||||
"oldest_first": "Ældste først",
|
||||
"a_to_z": "A → Å",
|
||||
"z_to_a": "Å → A",
|
||||
"largest_first": "Største først",
|
||||
"smallest_first": "Mindste først"
|
||||
},
|
||||
"add_level": "Tilføj niveau",
|
||||
"remove_level": "Fjern niveau",
|
||||
"move_up": "Flyt op",
|
||||
"move_down": "Flyt ned",
|
||||
"tiebreak_note": "Op til tre niveauer; senere niveauer afgør ved lighed. \"Nyeste først\" anvendes altid til sidst.",
|
||||
"scope": {
|
||||
"label": "Anvend på alle mapper",
|
||||
"description": "Når den er slået fra, gælder rækkefølgen kun for Indbakken; andre mapper forbliver kronologiske."
|
||||
},
|
||||
"unsupported_note": "Din mailserver understøtter ikke sortering efter læsestatus, stjerner eller mærkater. Disse niveauer ignoreres, og listen forbliver kronologisk."
|
||||
},
|
||||
"title": "Udseende",
|
||||
"description": "Tilpas udseendet af din webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Reihenfolge der Nachrichtenliste",
|
||||
"description": "Wie Nachrichten in der Liste sortiert werden. Die Sortierung erfolgt auf dem Server und gilt damit für den ganzen Ordner, nicht nur für die geladenen Nachrichten.",
|
||||
"preset": {
|
||||
"chronological": "Chronologisch (neueste zuerst)",
|
||||
"unread_first": "Ungelesene zuerst",
|
||||
"starred_first": "Markierte zuerst",
|
||||
"tagged_first": "Getaggte zuerst",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"tag_label": "Tag",
|
||||
"advanced_show": "Erweitert: Sortierebenen bearbeiten",
|
||||
"advanced_hide": "Erweitert ausblenden",
|
||||
"criterion_label": "Kriterium für Ebene {n}",
|
||||
"direction_label": "Richtung für Ebene {n}",
|
||||
"criteria": {
|
||||
"unread": "Ungelesen",
|
||||
"starred": "Markiert",
|
||||
"tag": "Tag",
|
||||
"receivedAt": "Empfangsdatum",
|
||||
"sentAt": "Sendedatum",
|
||||
"from": "Absender",
|
||||
"subject": "Betreff",
|
||||
"size": "Größe"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Ungelesene zuerst",
|
||||
"read_first": "Gelesene zuerst",
|
||||
"starred_first": "Markierte zuerst",
|
||||
"unstarred_first": "Nicht markierte zuerst",
|
||||
"tagged_first": "Getaggte zuerst",
|
||||
"untagged_first": "Nicht getaggte zuerst",
|
||||
"newest_first": "Neueste zuerst",
|
||||
"oldest_first": "Älteste zuerst",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Größte zuerst",
|
||||
"smallest_first": "Kleinste zuerst"
|
||||
},
|
||||
"add_level": "Ebene hinzufügen",
|
||||
"remove_level": "Ebene entfernen",
|
||||
"move_up": "Nach oben",
|
||||
"move_down": "Nach unten",
|
||||
"tiebreak_note": "Bis zu drei Ebenen; spätere Ebenen entscheiden bei Gleichstand. Neueste zuerst gilt immer als letzte Ebene.",
|
||||
"scope": {
|
||||
"label": "Auf alle Ordner anwenden",
|
||||
"description": "Wenn ausgeschaltet, gilt die Reihenfolge nur für den Posteingang; andere Ordner bleiben chronologisch."
|
||||
},
|
||||
"unsupported_note": "Dein Mailserver unterstützt keine Sortierung nach Lesestatus, Markierung oder Tags. Diese Ebenen werden ignoriert und die Liste bleibt chronologisch."
|
||||
},
|
||||
"title": "Darstellung",
|
||||
"description": "Passen Sie das Aussehen Ihres Webmails an",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Message list order",
|
||||
"description": "How messages are ordered in the list. The server does the ordering, so it covers the whole folder, not just the loaded messages.",
|
||||
"preset": {
|
||||
"chronological": "Chronological (newest first)",
|
||||
"unread_first": "Unread first",
|
||||
"starred_first": "Starred first",
|
||||
"tagged_first": "Tagged first",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tag_label": "Tag",
|
||||
"advanced_show": "Advanced: edit sort levels",
|
||||
"advanced_hide": "Hide advanced",
|
||||
"criterion_label": "Level {n} criterion",
|
||||
"direction_label": "Level {n} direction",
|
||||
"criteria": {
|
||||
"unread": "Unread",
|
||||
"starred": "Starred",
|
||||
"tag": "Tag",
|
||||
"receivedAt": "Date received",
|
||||
"sentAt": "Date sent",
|
||||
"from": "Sender",
|
||||
"subject": "Subject",
|
||||
"size": "Size"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Unread first",
|
||||
"read_first": "Read first",
|
||||
"starred_first": "Starred first",
|
||||
"unstarred_first": "Unstarred first",
|
||||
"tagged_first": "Tagged first",
|
||||
"untagged_first": "Untagged first",
|
||||
"newest_first": "Newest first",
|
||||
"oldest_first": "Oldest first",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Largest first",
|
||||
"smallest_first": "Smallest first"
|
||||
},
|
||||
"add_level": "Add level",
|
||||
"remove_level": "Remove level",
|
||||
"move_up": "Move up",
|
||||
"move_down": "Move down",
|
||||
"tiebreak_note": "Up to three levels; later levels break ties. Newest first is always applied last.",
|
||||
"scope": {
|
||||
"label": "Apply to all folders",
|
||||
"description": "When off, the order applies to the Inbox only; other folders stay chronological."
|
||||
},
|
||||
"unsupported_note": "Your mail server does not support ordering by read state, stars or tags. Those levels are ignored and the list stays chronological."
|
||||
},
|
||||
"title": "Appearance",
|
||||
"description": "Customize the look and feel of your webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Avanzado"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Orden de la lista de mensajes",
|
||||
"description": "Cómo se ordenan los mensajes en la lista. El servidor realiza la ordenación, por lo que abarca toda la carpeta, no solo los mensajes cargados.",
|
||||
"preset": {
|
||||
"chronological": "Cronológico (más recientes primero)",
|
||||
"unread_first": "No leídos primero",
|
||||
"starred_first": "Destacados primero",
|
||||
"tagged_first": "Etiquetados primero",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"tag_label": "Etiqueta",
|
||||
"advanced_show": "Avanzado: editar niveles de orden",
|
||||
"advanced_hide": "Ocultar avanzado",
|
||||
"criterion_label": "Criterio del nivel {n}",
|
||||
"direction_label": "Dirección del nivel {n}",
|
||||
"criteria": {
|
||||
"unread": "No leído",
|
||||
"starred": "Destacado",
|
||||
"tag": "Etiqueta",
|
||||
"receivedAt": "Fecha de recepción",
|
||||
"sentAt": "Fecha de envío",
|
||||
"from": "Remitente",
|
||||
"subject": "Asunto",
|
||||
"size": "Tamaño"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "No leídos primero",
|
||||
"read_first": "Leídos primero",
|
||||
"starred_first": "Destacados primero",
|
||||
"unstarred_first": "No destacados primero",
|
||||
"tagged_first": "Etiquetados primero",
|
||||
"untagged_first": "Sin etiqueta primero",
|
||||
"newest_first": "Más recientes primero",
|
||||
"oldest_first": "Más antiguos primero",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Más grandes primero",
|
||||
"smallest_first": "Más pequeños primero"
|
||||
},
|
||||
"add_level": "Añadir nivel",
|
||||
"remove_level": "Eliminar nivel",
|
||||
"move_up": "Subir",
|
||||
"move_down": "Bajar",
|
||||
"tiebreak_note": "Hasta tres niveles; los niveles posteriores deshacen empates. «Más recientes primero» se aplica siempre al final.",
|
||||
"scope": {
|
||||
"label": "Aplicar a todas las carpetas",
|
||||
"description": "Si está desactivado, el orden solo se aplica a la bandeja de entrada; las demás carpetas siguen siendo cronológicas."
|
||||
},
|
||||
"unsupported_note": "Tu servidor de correo no admite ordenar por estado de lectura, destacados o etiquetas. Esos niveles se ignoran y la lista sigue siendo cronológica."
|
||||
},
|
||||
"title": "Apariencia",
|
||||
"description": "Personalice la apariencia de su correo web",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "پیشرفته"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "ترتیب فهرست پیامها",
|
||||
"description": "نحوهٔ مرتبسازی پیامها در فهرست. مرتبسازی را سرور انجام میدهد، بنابراین کل پوشه را در بر میگیرد، نه فقط پیامهای بارگذاریشده را.",
|
||||
"preset": {
|
||||
"chronological": "زمانی (جدیدترین ابتدا)",
|
||||
"unread_first": "خواندهنشدهها ابتدا",
|
||||
"starred_first": "ستارهدارها ابتدا",
|
||||
"tagged_first": "برچسبدارها ابتدا",
|
||||
"custom": "سفارشی"
|
||||
},
|
||||
"tag_label": "برچسب",
|
||||
"advanced_show": "پیشرفته: ویرایش سطوح مرتبسازی",
|
||||
"advanced_hide": "پنهان کردن پیشرفته",
|
||||
"criterion_label": "معیار سطح {n}",
|
||||
"direction_label": "جهت سطح {n}",
|
||||
"criteria": {
|
||||
"unread": "خواندهنشده",
|
||||
"starred": "ستارهدار",
|
||||
"tag": "برچسب",
|
||||
"receivedAt": "تاریخ دریافت",
|
||||
"sentAt": "تاریخ ارسال",
|
||||
"from": "فرستنده",
|
||||
"subject": "موضوع",
|
||||
"size": "اندازه"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "خواندهنشدهها ابتدا",
|
||||
"read_first": "خواندهشدهها ابتدا",
|
||||
"starred_first": "ستارهدارها ابتدا",
|
||||
"unstarred_first": "بدون ستاره ابتدا",
|
||||
"tagged_first": "برچسبدارها ابتدا",
|
||||
"untagged_first": "بدون برچسب ابتدا",
|
||||
"newest_first": "جدیدترین ابتدا",
|
||||
"oldest_first": "قدیمیترین ابتدا",
|
||||
"a_to_z": "الف → ی",
|
||||
"z_to_a": "ی → الف",
|
||||
"largest_first": "بزرگترین ابتدا",
|
||||
"smallest_first": "کوچکترین ابتدا"
|
||||
},
|
||||
"add_level": "افزودن سطح",
|
||||
"remove_level": "حذف سطح",
|
||||
"move_up": "انتقال به بالا",
|
||||
"move_down": "انتقال به پایین",
|
||||
"tiebreak_note": "حداکثر سه سطح؛ سطوح بعدی در صورت تساوی تصمیم میگیرند. «جدیدترین ابتدا» همیشه در آخر اعمال میشود.",
|
||||
"scope": {
|
||||
"label": "اعمال بر همهٔ پوشهها",
|
||||
"description": "در حالت خاموش، ترتیب فقط بر صندوق ورودی اعمال میشود؛ پوشههای دیگر زمانی میمانند."
|
||||
},
|
||||
"unsupported_note": "سرور ایمیل شما مرتبسازی بر اساس وضعیت خواندن، ستاره یا برچسب را پشتیبانی نمیکند. این سطوح نادیده گرفته میشوند و فهرست زمانی میماند."
|
||||
},
|
||||
"title": "ظاهر",
|
||||
"description": "ظاهر و حس ایمیل تحت وب خود را سفارشی کنید",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Avancé"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ordre de la liste des messages",
|
||||
"description": "Comment les messages sont ordonnés dans la liste. Le tri est effectué par le serveur et couvre donc tout le dossier, pas seulement les messages chargés.",
|
||||
"preset": {
|
||||
"chronological": "Chronologique (plus récents d'abord)",
|
||||
"unread_first": "Non lus d'abord",
|
||||
"starred_first": "Suivis d'abord",
|
||||
"tagged_first": "Étiquetés d'abord",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"tag_label": "Étiquette",
|
||||
"advanced_show": "Avancé : modifier les niveaux de tri",
|
||||
"advanced_hide": "Masquer les options avancées",
|
||||
"criterion_label": "Critère du niveau {n}",
|
||||
"direction_label": "Sens du niveau {n}",
|
||||
"criteria": {
|
||||
"unread": "Non lu",
|
||||
"starred": "Suivi",
|
||||
"tag": "Étiquette",
|
||||
"receivedAt": "Date de réception",
|
||||
"sentAt": "Date d'envoi",
|
||||
"from": "Expéditeur",
|
||||
"subject": "Objet",
|
||||
"size": "Taille"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Non lus d'abord",
|
||||
"read_first": "Lus d'abord",
|
||||
"starred_first": "Suivis d'abord",
|
||||
"unstarred_first": "Non suivis d'abord",
|
||||
"tagged_first": "Étiquetés d'abord",
|
||||
"untagged_first": "Non étiquetés d'abord",
|
||||
"newest_first": "Plus récents d'abord",
|
||||
"oldest_first": "Plus anciens d'abord",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Plus volumineux d'abord",
|
||||
"smallest_first": "Plus petits d'abord"
|
||||
},
|
||||
"add_level": "Ajouter un niveau",
|
||||
"remove_level": "Supprimer le niveau",
|
||||
"move_up": "Monter",
|
||||
"move_down": "Descendre",
|
||||
"tiebreak_note": "Jusqu'à trois niveaux ; les niveaux suivants départagent les égalités. « Plus récents d'abord » s'applique toujours en dernier.",
|
||||
"scope": {
|
||||
"label": "Appliquer à tous les dossiers",
|
||||
"description": "Désactivé, l'ordre ne s'applique qu'à la boîte de réception ; les autres dossiers restent chronologiques."
|
||||
},
|
||||
"unsupported_note": "Votre serveur de messagerie ne prend pas en charge le tri par état de lecture, suivi ou étiquette. Ces niveaux sont ignorés et la liste reste chronologique."
|
||||
},
|
||||
"title": "Apparence",
|
||||
"description": "Personnalisez l'apparence de votre webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -886,6 +886,56 @@
|
||||
"advanced": "מתקדם"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "סדר רשימת ההודעות",
|
||||
"description": "איך ההודעות מסודרות ברשימה. המיון מתבצע בשרת, ולכן חל על כל התיקייה ולא רק על ההודעות שנטענו.",
|
||||
"preset": {
|
||||
"chronological": "כרונולוגי (החדשות תחילה)",
|
||||
"unread_first": "שלא נקראו תחילה",
|
||||
"starred_first": "מסומנות בכוכב תחילה",
|
||||
"tagged_first": "מתויגות תחילה",
|
||||
"custom": "מותאם אישית"
|
||||
},
|
||||
"tag_label": "תגית",
|
||||
"advanced_show": "מתקדם: עריכת רמות המיון",
|
||||
"advanced_hide": "הסתרת מתקדם",
|
||||
"criterion_label": "קריטריון רמה {n}",
|
||||
"direction_label": "כיוון רמה {n}",
|
||||
"criteria": {
|
||||
"unread": "לא נקרא",
|
||||
"starred": "מסומן בכוכב",
|
||||
"tag": "תגית",
|
||||
"receivedAt": "תאריך קבלה",
|
||||
"sentAt": "תאריך שליחה",
|
||||
"from": "שולח",
|
||||
"subject": "נושא",
|
||||
"size": "גודל"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "שלא נקראו תחילה",
|
||||
"read_first": "שנקראו תחילה",
|
||||
"starred_first": "מסומנות בכוכב תחילה",
|
||||
"unstarred_first": "ללא כוכב תחילה",
|
||||
"tagged_first": "מתויגות תחילה",
|
||||
"untagged_first": "ללא תגית תחילה",
|
||||
"newest_first": "החדשות תחילה",
|
||||
"oldest_first": "הישנות תחילה",
|
||||
"a_to_z": "א → ת",
|
||||
"z_to_a": "ת → א",
|
||||
"largest_first": "הגדולות תחילה",
|
||||
"smallest_first": "הקטנות תחילה"
|
||||
},
|
||||
"add_level": "הוספת רמה",
|
||||
"remove_level": "הסרת רמה",
|
||||
"move_up": "הזזה למעלה",
|
||||
"move_down": "הזזה למטה",
|
||||
"tiebreak_note": "עד שלוש רמות; הרמות הבאות מכריעות במקרה של שוויון. \"החדשות תחילה\" תמיד מוחל אחרון.",
|
||||
"scope": {
|
||||
"label": "החלה על כל התיקיות",
|
||||
"description": "כשכבוי, הסדר חל על תיבת הדואר הנכנס בלבד; שאר התיקיות נשארות כרונולוגיות."
|
||||
},
|
||||
"unsupported_note": "שרת הדואר שלך אינו תומך במיון לפי מצב קריאה, כוכב או תגיות. רמות אלה מתעלמות והרשימה נשארת כרונולוגית."
|
||||
},
|
||||
"title": "מראה",
|
||||
"description": "התאם אישית את המראה והתחושה של דואר האינטרנט שלך",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Speciális"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Üzenetlista sorrendje",
|
||||
"description": "Hogyan rendeződnek az üzenetek a listában. A rendezést a kiszolgáló végzi, így a teljes mappára vonatkozik, nem csak a betöltött üzenetekre.",
|
||||
"preset": {
|
||||
"chronological": "Időrendi (legújabb elöl)",
|
||||
"unread_first": "Olvasatlanok elöl",
|
||||
"starred_first": "Csillagozottak elöl",
|
||||
"tagged_first": "Címkézettek elöl",
|
||||
"custom": "Egyéni"
|
||||
},
|
||||
"tag_label": "Címke",
|
||||
"advanced_show": "Speciális: rendezési szintek szerkesztése",
|
||||
"advanced_hide": "Speciális elrejtése",
|
||||
"criterion_label": "{n}. szint feltétele",
|
||||
"direction_label": "{n}. szint iránya",
|
||||
"criteria": {
|
||||
"unread": "Olvasatlan",
|
||||
"starred": "Csillagozott",
|
||||
"tag": "Címke",
|
||||
"receivedAt": "Érkezés dátuma",
|
||||
"sentAt": "Küldés dátuma",
|
||||
"from": "Feladó",
|
||||
"subject": "Tárgy",
|
||||
"size": "Méret"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Olvasatlanok elöl",
|
||||
"read_first": "Olvasottak elöl",
|
||||
"starred_first": "Csillagozottak elöl",
|
||||
"unstarred_first": "Csillag nélküliek elöl",
|
||||
"tagged_first": "Címkézettek elöl",
|
||||
"untagged_first": "Címkézetlenek elöl",
|
||||
"newest_first": "Legújabb elöl",
|
||||
"oldest_first": "Legrégebbi elöl",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Legnagyobb elöl",
|
||||
"smallest_first": "Legkisebb elöl"
|
||||
},
|
||||
"add_level": "Szint hozzáadása",
|
||||
"remove_level": "Szint eltávolítása",
|
||||
"move_up": "Feljebb",
|
||||
"move_down": "Lejjebb",
|
||||
"tiebreak_note": "Legfeljebb három szint; a további szintek döntenek holtverseny esetén. A „legújabb elöl” mindig utolsóként érvényesül.",
|
||||
"scope": {
|
||||
"label": "Alkalmazás minden mappára",
|
||||
"description": "Kikapcsolva a sorrend csak a Beérkezett üzenetekre vonatkozik; a többi mappa időrendi marad."
|
||||
},
|
||||
"unsupported_note": "A levelezőkiszolgáló nem támogatja az olvasottság, csillag vagy címke szerinti rendezést. Ezeket a szinteket figyelmen kívül hagyja, és a lista időrendi marad."
|
||||
},
|
||||
"title": "Megjelenés",
|
||||
"description": "Webmail megjelenésének testreszabása",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Avanzate"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ordine dell'elenco messaggi",
|
||||
"description": "Come vengono ordinati i messaggi nell'elenco. L'ordinamento è eseguito dal server, quindi riguarda l'intera cartella e non solo i messaggi caricati.",
|
||||
"preset": {
|
||||
"chronological": "Cronologico (più recenti prima)",
|
||||
"unread_first": "Non letti prima",
|
||||
"starred_first": "Speciali prima",
|
||||
"tagged_first": "Con tag prima",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"tag_label": "Tag",
|
||||
"advanced_show": "Avanzate: modifica i livelli di ordinamento",
|
||||
"advanced_hide": "Nascondi avanzate",
|
||||
"criterion_label": "Criterio del livello {n}",
|
||||
"direction_label": "Direzione del livello {n}",
|
||||
"criteria": {
|
||||
"unread": "Non letto",
|
||||
"starred": "Speciale",
|
||||
"tag": "Tag",
|
||||
"receivedAt": "Data di ricezione",
|
||||
"sentAt": "Data di invio",
|
||||
"from": "Mittente",
|
||||
"subject": "Oggetto",
|
||||
"size": "Dimensione"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Non letti prima",
|
||||
"read_first": "Letti prima",
|
||||
"starred_first": "Speciali prima",
|
||||
"unstarred_first": "Non speciali prima",
|
||||
"tagged_first": "Con tag prima",
|
||||
"untagged_first": "Senza tag prima",
|
||||
"newest_first": "Più recenti prima",
|
||||
"oldest_first": "Più vecchi prima",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Più grandi prima",
|
||||
"smallest_first": "Più piccoli prima"
|
||||
},
|
||||
"add_level": "Aggiungi livello",
|
||||
"remove_level": "Rimuovi livello",
|
||||
"move_up": "Sposta su",
|
||||
"move_down": "Sposta giù",
|
||||
"tiebreak_note": "Fino a tre livelli; i livelli successivi risolvono i pareggi. «Più recenti prima» viene sempre applicato per ultimo.",
|
||||
"scope": {
|
||||
"label": "Applica a tutte le cartelle",
|
||||
"description": "Se disattivato, l'ordine vale solo per la Posta in arrivo; le altre cartelle restano cronologiche."
|
||||
},
|
||||
"unsupported_note": "Il tuo server di posta non supporta l'ordinamento per stato di lettura, speciali o tag. Questi livelli vengono ignorati e l'elenco resta cronologico."
|
||||
},
|
||||
"title": "Aspetto",
|
||||
"description": "Personalizza l'aspetto della tua webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "詳細"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "メッセージ一覧の並び順",
|
||||
"description": "一覧でメッセージをどう並べるか。並べ替えはサーバーが行うため、読み込み済みのメッセージだけでなくフォルダー全体に適用されます。",
|
||||
"preset": {
|
||||
"chronological": "時系列(新しい順)",
|
||||
"unread_first": "未読を先に",
|
||||
"starred_first": "スター付きを先に",
|
||||
"tagged_first": "タグ付きを先に",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"tag_label": "タグ",
|
||||
"advanced_show": "詳細: 並べ替えレベルを編集",
|
||||
"advanced_hide": "詳細を隠す",
|
||||
"criterion_label": "レベル {n} の条件",
|
||||
"direction_label": "レベル {n} の方向",
|
||||
"criteria": {
|
||||
"unread": "未読",
|
||||
"starred": "スター付き",
|
||||
"tag": "タグ",
|
||||
"receivedAt": "受信日時",
|
||||
"sentAt": "送信日時",
|
||||
"from": "送信者",
|
||||
"subject": "件名",
|
||||
"size": "サイズ"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "未読を先に",
|
||||
"read_first": "既読を先に",
|
||||
"starred_first": "スター付きを先に",
|
||||
"unstarred_first": "スターなしを先に",
|
||||
"tagged_first": "タグ付きを先に",
|
||||
"untagged_first": "タグなしを先に",
|
||||
"newest_first": "新しい順",
|
||||
"oldest_first": "古い順",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "大きい順",
|
||||
"smallest_first": "小さい順"
|
||||
},
|
||||
"add_level": "レベルを追加",
|
||||
"remove_level": "レベルを削除",
|
||||
"move_up": "上へ移動",
|
||||
"move_down": "下へ移動",
|
||||
"tiebreak_note": "最大 3 レベル。後のレベルは同順位の場合に使われます。「新しい順」は常に最後に適用されます。",
|
||||
"scope": {
|
||||
"label": "すべてのフォルダーに適用",
|
||||
"description": "オフの場合、並び順は受信トレイにのみ適用され、他のフォルダーは時系列のままです。"
|
||||
},
|
||||
"unsupported_note": "お使いのメールサーバーは既読状態・スター・タグによる並べ替えに対応していません。これらのレベルは無視され、一覧は時系列のままになります。"
|
||||
},
|
||||
"title": "外観",
|
||||
"description": "ウェブメールの見た目をカスタマイズ",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "고급"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "메시지 목록 정렬 순서",
|
||||
"description": "목록에서 메시지를 정렬하는 방식입니다. 정렬은 서버가 수행하므로 불러온 메시지뿐 아니라 폴더 전체에 적용됩니다.",
|
||||
"preset": {
|
||||
"chronological": "시간순 (최신 먼저)",
|
||||
"unread_first": "읽지 않음 먼저",
|
||||
"starred_first": "별표 먼저",
|
||||
"tagged_first": "태그 먼저",
|
||||
"custom": "사용자 지정"
|
||||
},
|
||||
"tag_label": "태그",
|
||||
"advanced_show": "고급: 정렬 단계 편집",
|
||||
"advanced_hide": "고급 숨기기",
|
||||
"criterion_label": "{n}단계 기준",
|
||||
"direction_label": "{n}단계 방향",
|
||||
"criteria": {
|
||||
"unread": "읽지 않음",
|
||||
"starred": "별표",
|
||||
"tag": "태그",
|
||||
"receivedAt": "받은 날짜",
|
||||
"sentAt": "보낸 날짜",
|
||||
"from": "보낸 사람",
|
||||
"subject": "제목",
|
||||
"size": "크기"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "읽지 않음 먼저",
|
||||
"read_first": "읽음 먼저",
|
||||
"starred_first": "별표 먼저",
|
||||
"unstarred_first": "별표 없음 먼저",
|
||||
"tagged_first": "태그 있음 먼저",
|
||||
"untagged_first": "태그 없음 먼저",
|
||||
"newest_first": "최신 먼저",
|
||||
"oldest_first": "오래된 순",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "큰 순서",
|
||||
"smallest_first": "작은 순서"
|
||||
},
|
||||
"add_level": "단계 추가",
|
||||
"remove_level": "단계 제거",
|
||||
"move_up": "위로 이동",
|
||||
"move_down": "아래로 이동",
|
||||
"tiebreak_note": "최대 3단계이며, 이후 단계는 동순위일 때 적용됩니다. '최신 먼저'는 항상 마지막에 적용됩니다.",
|
||||
"scope": {
|
||||
"label": "모든 폴더에 적용",
|
||||
"description": "꺼져 있으면 정렬 순서가 받은편지함에만 적용되고 다른 폴더는 시간순으로 유지됩니다."
|
||||
},
|
||||
"unsupported_note": "메일 서버가 읽음 상태, 별표 또는 태그 기준 정렬을 지원하지 않습니다. 해당 단계는 무시되며 목록은 시간순으로 유지됩니다."
|
||||
},
|
||||
"title": "화면 설정",
|
||||
"description": "웹메일의 디자인과 화면을 내 취향에 맞게 꾸며보세요",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Papildu"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ziņu saraksta secība",
|
||||
"description": "Kā ziņas tiek sakārtotas sarakstā. Kārtošanu veic serveris, tāpēc tā attiecas uz visu mapi, ne tikai uz ielādētajām ziņām.",
|
||||
"preset": {
|
||||
"chronological": "Hronoloģiski (jaunākās vispirms)",
|
||||
"unread_first": "Nelasītās vispirms",
|
||||
"starred_first": "Ar zvaigznīti vispirms",
|
||||
"tagged_first": "Ar birku vispirms",
|
||||
"custom": "Pielāgota"
|
||||
},
|
||||
"tag_label": "Birka",
|
||||
"advanced_show": "Papildu: rediģēt kārtošanas līmeņus",
|
||||
"advanced_hide": "Paslēpt papildu",
|
||||
"criterion_label": "{n}. līmeņa kritērijs",
|
||||
"direction_label": "{n}. līmeņa virziens",
|
||||
"criteria": {
|
||||
"unread": "Nelasīta",
|
||||
"starred": "Ar zvaigznīti",
|
||||
"tag": "Birka",
|
||||
"receivedAt": "Saņemšanas datums",
|
||||
"sentAt": "Nosūtīšanas datums",
|
||||
"from": "Sūtītājs",
|
||||
"subject": "Tēma",
|
||||
"size": "Izmērs"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Nelasītās vispirms",
|
||||
"read_first": "Lasītās vispirms",
|
||||
"starred_first": "Ar zvaigznīti vispirms",
|
||||
"unstarred_first": "Bez zvaigznītes vispirms",
|
||||
"tagged_first": "Ar birku vispirms",
|
||||
"untagged_first": "Bez birkas vispirms",
|
||||
"newest_first": "Jaunākās vispirms",
|
||||
"oldest_first": "Vecākās vispirms",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Lielākās vispirms",
|
||||
"smallest_first": "Mazākās vispirms"
|
||||
},
|
||||
"add_level": "Pievienot līmeni",
|
||||
"remove_level": "Noņemt līmeni",
|
||||
"move_up": "Pārvietot augstāk",
|
||||
"move_down": "Pārvietot zemāk",
|
||||
"tiebreak_note": "Līdz trim līmeņiem; nākamie līmeņi izšķir neizšķirtu. „Jaunākās vispirms” vienmēr tiek piemērots pēdējais.",
|
||||
"scope": {
|
||||
"label": "Piemērot visām mapēm",
|
||||
"description": "Ja izslēgts, secība attiecas tikai uz Iesūtni; pārējās mapes paliek hronoloģiskas."
|
||||
},
|
||||
"unsupported_note": "Jūsu pasta serveris neatbalsta kārtošanu pēc lasīšanas stāvokļa, zvaigznītēm vai birkām. Šie līmeņi tiek ignorēti, un saraksts paliek hronoloģisks."
|
||||
},
|
||||
"title": "Izskats",
|
||||
"description": "Pielāgojiet tīmekļa pasta izskatu",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Дэвшилтэт"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Зурвасын жагсаалтын дараалал",
|
||||
"description": "Зурвасууд жагсаалтад хэрхэн эрэмбэлэгдэх. Эрэмбэлэлтийг сервер хийдэг тул зөвхөн ачаалагдсан зурвас биш, бүх хавтсанд үйлчилнэ.",
|
||||
"preset": {
|
||||
"chronological": "Он цагийн дарааллаар (шинэ нь эхэнд)",
|
||||
"unread_first": "Уншаагүй нь эхэнд",
|
||||
"starred_first": "Одтой нь эхэнд",
|
||||
"tagged_first": "Шошготой нь эхэнд",
|
||||
"custom": "Өөрийн"
|
||||
},
|
||||
"tag_label": "Шошго",
|
||||
"advanced_show": "Нарийвчилсан: эрэмбийн түвшнүүдийг засах",
|
||||
"advanced_hide": "Нарийвчилсныг нуух",
|
||||
"criterion_label": "{n}-р түвшний шалгуур",
|
||||
"direction_label": "{n}-р түвшний чиглэл",
|
||||
"criteria": {
|
||||
"unread": "Уншаагүй",
|
||||
"starred": "Одтой",
|
||||
"tag": "Шошго",
|
||||
"receivedAt": "Хүлээн авсан огноо",
|
||||
"sentAt": "Илгээсэн огноо",
|
||||
"from": "Илгээгч",
|
||||
"subject": "Гарчиг",
|
||||
"size": "Хэмжээ"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Уншаагүй нь эхэнд",
|
||||
"read_first": "Уншсан нь эхэнд",
|
||||
"starred_first": "Одтой нь эхэнд",
|
||||
"unstarred_first": "Одгүй нь эхэнд",
|
||||
"tagged_first": "Шошготой нь эхэнд",
|
||||
"untagged_first": "Шошгогүй нь эхэнд",
|
||||
"newest_first": "Шинэ нь эхэнд",
|
||||
"oldest_first": "Хуучин нь эхэнд",
|
||||
"a_to_z": "А → Я",
|
||||
"z_to_a": "Я → А",
|
||||
"largest_first": "Том нь эхэнд",
|
||||
"smallest_first": "Жижиг нь эхэнд"
|
||||
},
|
||||
"add_level": "Түвшин нэмэх",
|
||||
"remove_level": "Түвшин устгах",
|
||||
"move_up": "Дээш зөөх",
|
||||
"move_down": "Доош зөөх",
|
||||
"tiebreak_note": "Дээд тал нь гурван түвшин; дараагийн түвшнүүд тэнцсэн үед шийднэ. «Шинэ нь эхэнд» үргэлж хамгийн сүүлд хэрэглэгдэнэ.",
|
||||
"scope": {
|
||||
"label": "Бүх хавтсанд хэрэглэх",
|
||||
"description": "Унтраалттай үед дараалал зөвхөн Ирсэн хавтсанд үйлчилнэ; бусад хавтас он цагийн дарааллаараа үлдэнэ."
|
||||
},
|
||||
"unsupported_note": "Таны мэйл сервер уншсан төлөв, од эсвэл шошгоор эрэмбэлэхийг дэмждэггүй. Эдгээр түвшнийг алгасаж, жагсаалт он цагийн дарааллаараа үлдэнэ."
|
||||
},
|
||||
"title": "Гадаад төрх",
|
||||
"description": "Вэбмэйлийнхээ харагдах байдал, мэдрэмжийг өөрчилнө",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Geavanceerd"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Volgorde van de berichtenlijst",
|
||||
"description": "Hoe berichten in de lijst worden gesorteerd. De server sorteert, dus het geldt voor de hele map en niet alleen voor de geladen berichten.",
|
||||
"preset": {
|
||||
"chronological": "Chronologisch (nieuwste eerst)",
|
||||
"unread_first": "Ongelezen eerst",
|
||||
"starred_first": "Met ster eerst",
|
||||
"tagged_first": "Met label eerst",
|
||||
"custom": "Aangepast"
|
||||
},
|
||||
"tag_label": "Label",
|
||||
"advanced_show": "Geavanceerd: sorteerniveaus bewerken",
|
||||
"advanced_hide": "Geavanceerd verbergen",
|
||||
"criterion_label": "Criterium van niveau {n}",
|
||||
"direction_label": "Richting van niveau {n}",
|
||||
"criteria": {
|
||||
"unread": "Ongelezen",
|
||||
"starred": "Met ster",
|
||||
"tag": "Label",
|
||||
"receivedAt": "Ontvangstdatum",
|
||||
"sentAt": "Verzenddatum",
|
||||
"from": "Afzender",
|
||||
"subject": "Onderwerp",
|
||||
"size": "Grootte"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Ongelezen eerst",
|
||||
"read_first": "Gelezen eerst",
|
||||
"starred_first": "Met ster eerst",
|
||||
"unstarred_first": "Zonder ster eerst",
|
||||
"tagged_first": "Met label eerst",
|
||||
"untagged_first": "Zonder label eerst",
|
||||
"newest_first": "Nieuwste eerst",
|
||||
"oldest_first": "Oudste eerst",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Grootste eerst",
|
||||
"smallest_first": "Kleinste eerst"
|
||||
},
|
||||
"add_level": "Niveau toevoegen",
|
||||
"remove_level": "Niveau verwijderen",
|
||||
"move_up": "Omhoog",
|
||||
"move_down": "Omlaag",
|
||||
"tiebreak_note": "Maximaal drie niveaus; latere niveaus beslissen bij gelijke stand. 'Nieuwste eerst' wordt altijd als laatste toegepast.",
|
||||
"scope": {
|
||||
"label": "Op alle mappen toepassen",
|
||||
"description": "Uitgeschakeld geldt de volgorde alleen voor het Postvak IN; andere mappen blijven chronologisch."
|
||||
},
|
||||
"unsupported_note": "Je mailserver ondersteunt geen sortering op leesstatus, ster of label. Die niveaus worden genegeerd en de lijst blijft chronologisch."
|
||||
},
|
||||
"title": "Uiterlijk",
|
||||
"description": "Pas het uiterlijk van je webmail aan",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Zaawansowane"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Kolejność listy wiadomości",
|
||||
"description": "Jak wiadomości są uporządkowane na liście. Sortowanie wykonuje serwer, więc obejmuje cały folder, a nie tylko załadowane wiadomości.",
|
||||
"preset": {
|
||||
"chronological": "Chronologicznie (najnowsze najpierw)",
|
||||
"unread_first": "Nieprzeczytane najpierw",
|
||||
"starred_first": "Oznaczone gwiazdką najpierw",
|
||||
"tagged_first": "Z etykietą najpierw",
|
||||
"custom": "Własna"
|
||||
},
|
||||
"tag_label": "Etykieta",
|
||||
"advanced_show": "Zaawansowane: edytuj poziomy sortowania",
|
||||
"advanced_hide": "Ukryj zaawansowane",
|
||||
"criterion_label": "Kryterium poziomu {n}",
|
||||
"direction_label": "Kierunek poziomu {n}",
|
||||
"criteria": {
|
||||
"unread": "Nieprzeczytane",
|
||||
"starred": "Z gwiazdką",
|
||||
"tag": "Etykieta",
|
||||
"receivedAt": "Data odebrania",
|
||||
"sentAt": "Data wysłania",
|
||||
"from": "Nadawca",
|
||||
"subject": "Temat",
|
||||
"size": "Rozmiar"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Nieprzeczytane najpierw",
|
||||
"read_first": "Przeczytane najpierw",
|
||||
"starred_first": "Z gwiazdką najpierw",
|
||||
"unstarred_first": "Bez gwiazdki najpierw",
|
||||
"tagged_first": "Z etykietą najpierw",
|
||||
"untagged_first": "Bez etykiety najpierw",
|
||||
"newest_first": "Najnowsze najpierw",
|
||||
"oldest_first": "Najstarsze najpierw",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Największe najpierw",
|
||||
"smallest_first": "Najmniejsze najpierw"
|
||||
},
|
||||
"add_level": "Dodaj poziom",
|
||||
"remove_level": "Usuń poziom",
|
||||
"move_up": "Przenieś wyżej",
|
||||
"move_down": "Przenieś niżej",
|
||||
"tiebreak_note": "Do trzech poziomów; kolejne poziomy rozstrzygają remisy. „Najnowsze najpierw” jest zawsze stosowane na końcu.",
|
||||
"scope": {
|
||||
"label": "Zastosuj do wszystkich folderów",
|
||||
"description": "Gdy wyłączone, kolejność dotyczy tylko Odebranych; pozostałe foldery pozostają chronologiczne."
|
||||
},
|
||||
"unsupported_note": "Twój serwer poczty nie obsługuje sortowania według stanu przeczytania, gwiazdek ani etykiet. Te poziomy są pomijane, a lista pozostaje chronologiczna."
|
||||
},
|
||||
"title": "Wygląd",
|
||||
"description": "Dostosuj wygląd i działanie swojej poczty webowej",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Avançado"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ordem da lista de mensagens",
|
||||
"description": "Como as mensagens são ordenadas na lista. A ordenação é feita pelo servidor, por isso abrange toda a pasta e não apenas as mensagens carregadas.",
|
||||
"preset": {
|
||||
"chronological": "Cronológica (mais recentes primeiro)",
|
||||
"unread_first": "Não lidas primeiro",
|
||||
"starred_first": "Com estrela primeiro",
|
||||
"tagged_first": "Com etiqueta primeiro",
|
||||
"custom": "Personalizada"
|
||||
},
|
||||
"tag_label": "Etiqueta",
|
||||
"advanced_show": "Avançado: editar níveis de ordenação",
|
||||
"advanced_hide": "Ocultar avançado",
|
||||
"criterion_label": "Critério do nível {n}",
|
||||
"direction_label": "Direção do nível {n}",
|
||||
"criteria": {
|
||||
"unread": "Não lida",
|
||||
"starred": "Com estrela",
|
||||
"tag": "Etiqueta",
|
||||
"receivedAt": "Data de receção",
|
||||
"sentAt": "Data de envio",
|
||||
"from": "Remetente",
|
||||
"subject": "Assunto",
|
||||
"size": "Tamanho"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Não lidas primeiro",
|
||||
"read_first": "Lidas primeiro",
|
||||
"starred_first": "Com estrela primeiro",
|
||||
"unstarred_first": "Sem estrela primeiro",
|
||||
"tagged_first": "Com etiqueta primeiro",
|
||||
"untagged_first": "Sem etiqueta primeiro",
|
||||
"newest_first": "Mais recentes primeiro",
|
||||
"oldest_first": "Mais antigas primeiro",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Maiores primeiro",
|
||||
"smallest_first": "Menores primeiro"
|
||||
},
|
||||
"add_level": "Adicionar nível",
|
||||
"remove_level": "Remover nível",
|
||||
"move_up": "Mover para cima",
|
||||
"move_down": "Mover para baixo",
|
||||
"tiebreak_note": "Até três níveis; os níveis seguintes desempatam. «Mais recentes primeiro» é sempre aplicado por último.",
|
||||
"scope": {
|
||||
"label": "Aplicar a todas as pastas",
|
||||
"description": "Desativado, a ordem aplica-se apenas à Caixa de entrada; as outras pastas continuam cronológicas."
|
||||
},
|
||||
"unsupported_note": "O seu servidor de correio não suporta ordenação por estado de leitura, estrelas ou etiquetas. Esses níveis são ignorados e a lista mantém-se cronológica."
|
||||
},
|
||||
"title": "Aparência",
|
||||
"description": "Personalize a aparência do seu webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Nivel avansat"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Ordinea listei de mesaje",
|
||||
"description": "Cum sunt ordonate mesajele în listă. Sortarea este făcută de server, deci acoperă întregul dosar, nu doar mesajele încărcate.",
|
||||
"preset": {
|
||||
"chronological": "Cronologic (cele mai noi primele)",
|
||||
"unread_first": "Necitite primele",
|
||||
"starred_first": "Cu stea primele",
|
||||
"tagged_first": "Etichetate primele",
|
||||
"custom": "Personalizat"
|
||||
},
|
||||
"tag_label": "Etichetă",
|
||||
"advanced_show": "Avansat: editează nivelurile de sortare",
|
||||
"advanced_hide": "Ascunde avansat",
|
||||
"criterion_label": "Criteriul nivelului {n}",
|
||||
"direction_label": "Direcția nivelului {n}",
|
||||
"criteria": {
|
||||
"unread": "Necitit",
|
||||
"starred": "Cu stea",
|
||||
"tag": "Etichetă",
|
||||
"receivedAt": "Data primirii",
|
||||
"sentAt": "Data trimiterii",
|
||||
"from": "Expeditor",
|
||||
"subject": "Subiect",
|
||||
"size": "Dimensiune"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Necitite primele",
|
||||
"read_first": "Citite primele",
|
||||
"starred_first": "Cu stea primele",
|
||||
"unstarred_first": "Fără stea primele",
|
||||
"tagged_first": "Etichetate primele",
|
||||
"untagged_first": "Neetichetate primele",
|
||||
"newest_first": "Cele mai noi primele",
|
||||
"oldest_first": "Cele mai vechi primele",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Cele mai mari primele",
|
||||
"smallest_first": "Cele mai mici primele"
|
||||
},
|
||||
"add_level": "Adaugă nivel",
|
||||
"remove_level": "Elimină nivelul",
|
||||
"move_up": "Mută în sus",
|
||||
"move_down": "Mută în jos",
|
||||
"tiebreak_note": "Până la trei niveluri; nivelurile următoare departajează egalitățile. „Cele mai noi primele” se aplică întotdeauna la final.",
|
||||
"scope": {
|
||||
"label": "Aplică la toate dosarele",
|
||||
"description": "Dezactivat, ordinea se aplică doar la Inbox; celelalte dosare rămân cronologice."
|
||||
},
|
||||
"unsupported_note": "Serverul tău de e-mail nu acceptă sortarea după starea de citire, stele sau etichete. Aceste niveluri sunt ignorate, iar lista rămâne cronologică."
|
||||
},
|
||||
"title": "Aspect",
|
||||
"description": "Personalizați aspectul și funcționalitatea serviciului dvs. de webmail",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Дополнительно"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Порядок списка сообщений",
|
||||
"description": "Как упорядочены сообщения в списке. Сортировку выполняет сервер, поэтому она охватывает всю папку, а не только загруженные сообщения.",
|
||||
"preset": {
|
||||
"chronological": "Хронологически (сначала новые)",
|
||||
"unread_first": "Сначала непрочитанные",
|
||||
"starred_first": "Сначала помеченные",
|
||||
"tagged_first": "Сначала с меткой",
|
||||
"custom": "Свой порядок"
|
||||
},
|
||||
"tag_label": "Метка",
|
||||
"advanced_show": "Дополнительно: настроить уровни сортировки",
|
||||
"advanced_hide": "Скрыть дополнительно",
|
||||
"criterion_label": "Критерий уровня {n}",
|
||||
"direction_label": "Направление уровня {n}",
|
||||
"criteria": {
|
||||
"unread": "Непрочитанные",
|
||||
"starred": "Помеченные",
|
||||
"tag": "Метка",
|
||||
"receivedAt": "Дата получения",
|
||||
"sentAt": "Дата отправки",
|
||||
"from": "Отправитель",
|
||||
"subject": "Тема",
|
||||
"size": "Размер"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Сначала непрочитанные",
|
||||
"read_first": "Сначала прочитанные",
|
||||
"starred_first": "Сначала помеченные",
|
||||
"unstarred_first": "Сначала непомеченные",
|
||||
"tagged_first": "Сначала с меткой",
|
||||
"untagged_first": "Сначала без метки",
|
||||
"newest_first": "Сначала новые",
|
||||
"oldest_first": "Сначала старые",
|
||||
"a_to_z": "А → Я",
|
||||
"z_to_a": "Я → А",
|
||||
"largest_first": "Сначала большие",
|
||||
"smallest_first": "Сначала маленькие"
|
||||
},
|
||||
"add_level": "Добавить уровень",
|
||||
"remove_level": "Удалить уровень",
|
||||
"move_up": "Выше",
|
||||
"move_down": "Ниже",
|
||||
"tiebreak_note": "До трёх уровней; следующие уровни решают при равенстве. «Сначала новые» всегда применяется последним.",
|
||||
"scope": {
|
||||
"label": "Применять ко всем папкам",
|
||||
"description": "Если выключено, порядок действует только для папки «Входящие»; остальные папки остаются хронологическими."
|
||||
},
|
||||
"unsupported_note": "Ваш почтовый сервер не поддерживает сортировку по статусу прочтения, пометкам или меткам. Эти уровни игнорируются, и список остаётся хронологическим."
|
||||
},
|
||||
"title": "Внешний вид",
|
||||
"description": "Настройте внешний вид веб-почты",
|
||||
"theme": {
|
||||
|
||||
@@ -924,6 +924,56 @@
|
||||
"advanced": "Pokročilé"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Poradie zoznamu správ",
|
||||
"description": "Ako sú správy v zozname zoradené. Zoradenie vykonáva server, takže platí pre celý priečinok, nie len pre načítané správy.",
|
||||
"preset": {
|
||||
"chronological": "Chronologicky (najnovšie prvé)",
|
||||
"unread_first": "Neprečítané prvé",
|
||||
"starred_first": "S hviezdičkou prvé",
|
||||
"tagged_first": "So štítkom prvé",
|
||||
"custom": "Vlastné"
|
||||
},
|
||||
"tag_label": "Štítok",
|
||||
"advanced_show": "Rozšírené: upraviť úrovne zoradenia",
|
||||
"advanced_hide": "Skryť rozšírené",
|
||||
"criterion_label": "Kritérium úrovne {n}",
|
||||
"direction_label": "Smer úrovne {n}",
|
||||
"criteria": {
|
||||
"unread": "Neprečítané",
|
||||
"starred": "S hviezdičkou",
|
||||
"tag": "Štítok",
|
||||
"receivedAt": "Dátum prijatia",
|
||||
"sentAt": "Dátum odoslania",
|
||||
"from": "Odosielateľ",
|
||||
"subject": "Predmet",
|
||||
"size": "Veľkosť"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Neprečítané prvé",
|
||||
"read_first": "Prečítané prvé",
|
||||
"starred_first": "S hviezdičkou prvé",
|
||||
"unstarred_first": "Bez hviezdičky prvé",
|
||||
"tagged_first": "So štítkom prvé",
|
||||
"untagged_first": "Bez štítku prvé",
|
||||
"newest_first": "Najnovšie prvé",
|
||||
"oldest_first": "Najstaršie prvé",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "Najväčšie prvé",
|
||||
"smallest_first": "Najmenšie prvé"
|
||||
},
|
||||
"add_level": "Pridať úroveň",
|
||||
"remove_level": "Odstrániť úroveň",
|
||||
"move_up": "Posunúť nahor",
|
||||
"move_down": "Posunúť nadol",
|
||||
"tiebreak_note": "Až tri úrovne; ďalšie úrovne rozhodujú pri zhode. „Najnovšie prvé“ sa vždy uplatní ako posledné.",
|
||||
"scope": {
|
||||
"label": "Použiť na všetky priečinky",
|
||||
"description": "Ak je vypnuté, poradie platí len pre Doručenú poštu; ostatné priečinky zostávajú chronologické."
|
||||
},
|
||||
"unsupported_note": "Váš poštový server nepodporuje zoradenie podľa stavu prečítania, hviezdičiek ani štítkov. Tieto úrovne sa ignorujú a zoznam zostáva chronologický."
|
||||
},
|
||||
"title": "Vzhľad",
|
||||
"description": "Prispôsobte si vzhľad a správanie webmailu",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Gelişmiş"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "İleti listesi sıralaması",
|
||||
"description": "İletilerin listede nasıl sıralandığı. Sıralamayı sunucu yapar; bu yüzden yalnızca yüklenen iletiler değil, klasörün tamamı sıralanır.",
|
||||
"preset": {
|
||||
"chronological": "Kronolojik (en yeni önce)",
|
||||
"unread_first": "Okunmamışlar önce",
|
||||
"starred_first": "Yıldızlılar önce",
|
||||
"tagged_first": "Etiketliler önce",
|
||||
"custom": "Özel"
|
||||
},
|
||||
"tag_label": "Etiket",
|
||||
"advanced_show": "Gelişmiş: sıralama düzeylerini düzenle",
|
||||
"advanced_hide": "Gelişmişi gizle",
|
||||
"criterion_label": "{n}. düzey ölçütü",
|
||||
"direction_label": "{n}. düzey yönü",
|
||||
"criteria": {
|
||||
"unread": "Okunmamış",
|
||||
"starred": "Yıldızlı",
|
||||
"tag": "Etiket",
|
||||
"receivedAt": "Alınma tarihi",
|
||||
"sentAt": "Gönderilme tarihi",
|
||||
"from": "Gönderen",
|
||||
"subject": "Konu",
|
||||
"size": "Boyut"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Okunmamışlar önce",
|
||||
"read_first": "Okunmuşlar önce",
|
||||
"starred_first": "Yıldızlılar önce",
|
||||
"unstarred_first": "Yıldızsızlar önce",
|
||||
"tagged_first": "Etiketliler önce",
|
||||
"untagged_first": "Etiketsizler önce",
|
||||
"newest_first": "En yeni önce",
|
||||
"oldest_first": "En eski önce",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "En büyük önce",
|
||||
"smallest_first": "En küçük önce"
|
||||
},
|
||||
"add_level": "Düzey ekle",
|
||||
"remove_level": "Düzeyi kaldır",
|
||||
"move_up": "Yukarı taşı",
|
||||
"move_down": "Aşağı taşı",
|
||||
"tiebreak_note": "En fazla üç düzey; sonraki düzeyler eşitlikleri çözer. \"En yeni önce\" her zaman en son uygulanır.",
|
||||
"scope": {
|
||||
"label": "Tüm klasörlere uygula",
|
||||
"description": "Kapalıyken sıralama yalnızca Gelen Kutusu için geçerlidir; diğer klasörler kronolojik kalır."
|
||||
},
|
||||
"unsupported_note": "Posta sunucunuz okunma durumu, yıldız veya etikete göre sıralamayı desteklemiyor. Bu düzeyler yok sayılır ve liste kronolojik kalır."
|
||||
},
|
||||
"title": "Görünüm",
|
||||
"description": "Web postanızın görünümünü özelleştirin",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "Додатково"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "Порядок списку повідомлень",
|
||||
"description": "Як упорядковано повідомлення в списку. Сортування виконує сервер, тому воно охоплює всю теку, а не лише завантажені повідомлення.",
|
||||
"preset": {
|
||||
"chronological": "Хронологічно (спочатку нові)",
|
||||
"unread_first": "Спочатку непрочитані",
|
||||
"starred_first": "Спочатку із зірочкою",
|
||||
"tagged_first": "Спочатку з міткою",
|
||||
"custom": "Власний"
|
||||
},
|
||||
"tag_label": "Мітка",
|
||||
"advanced_show": "Додатково: змінити рівні сортування",
|
||||
"advanced_hide": "Сховати додаткові",
|
||||
"criterion_label": "Критерій рівня {n}",
|
||||
"direction_label": "Напрямок рівня {n}",
|
||||
"criteria": {
|
||||
"unread": "Непрочитані",
|
||||
"starred": "Із зірочкою",
|
||||
"tag": "Мітка",
|
||||
"receivedAt": "Дата отримання",
|
||||
"sentAt": "Дата надсилання",
|
||||
"from": "Відправник",
|
||||
"subject": "Тема",
|
||||
"size": "Розмір"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "Спочатку непрочитані",
|
||||
"read_first": "Спочатку прочитані",
|
||||
"starred_first": "Спочатку із зірочкою",
|
||||
"unstarred_first": "Спочатку без зірочки",
|
||||
"tagged_first": "Спочатку з міткою",
|
||||
"untagged_first": "Спочатку без мітки",
|
||||
"newest_first": "Спочатку нові",
|
||||
"oldest_first": "Спочатку старі",
|
||||
"a_to_z": "А → Я",
|
||||
"z_to_a": "Я → А",
|
||||
"largest_first": "Спочатку великі",
|
||||
"smallest_first": "Спочатку малі"
|
||||
},
|
||||
"add_level": "Додати рівень",
|
||||
"remove_level": "Вилучити рівень",
|
||||
"move_up": "Вище",
|
||||
"move_down": "Нижче",
|
||||
"tiebreak_note": "До трьох рівнів; наступні рівні вирішують за рівності. «Спочатку нові» завжди застосовується останнім.",
|
||||
"scope": {
|
||||
"label": "Застосувати до всіх тек",
|
||||
"description": "Якщо вимкнено, порядок діє лише для теки «Вхідні»; інші теки лишаються хронологічними."
|
||||
},
|
||||
"unsupported_note": "Ваш поштовий сервер не підтримує сортування за станом прочитання, зірочками чи мітками. Ці рівні ігноруються, і список лишається хронологічним."
|
||||
},
|
||||
"title": "Зовнішній вигляд",
|
||||
"description": "Налаштуйте зовнішній вигляд вашої веб-пошти",
|
||||
"theme": {
|
||||
|
||||
@@ -921,6 +921,56 @@
|
||||
"advanced": "高级"
|
||||
},
|
||||
"appearance": {
|
||||
"message_list_order": {
|
||||
"label": "邮件列表排序",
|
||||
"description": "列表中邮件的排序方式。排序由服务器完成,因此作用于整个文件夹,而不仅是已加载的邮件。",
|
||||
"preset": {
|
||||
"chronological": "按时间(最新在前)",
|
||||
"unread_first": "未读优先",
|
||||
"starred_first": "星标优先",
|
||||
"tagged_first": "标签优先",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"tag_label": "标签",
|
||||
"advanced_show": "高级:编辑排序层级",
|
||||
"advanced_hide": "隐藏高级选项",
|
||||
"criterion_label": "第 {n} 级条件",
|
||||
"direction_label": "第 {n} 级方向",
|
||||
"criteria": {
|
||||
"unread": "未读",
|
||||
"starred": "星标",
|
||||
"tag": "标签",
|
||||
"receivedAt": "接收日期",
|
||||
"sentAt": "发送日期",
|
||||
"from": "发件人",
|
||||
"subject": "主题",
|
||||
"size": "大小"
|
||||
},
|
||||
"direction": {
|
||||
"unread_first": "未读在前",
|
||||
"read_first": "已读在前",
|
||||
"starred_first": "星标在前",
|
||||
"unstarred_first": "无星标在前",
|
||||
"tagged_first": "有标签在前",
|
||||
"untagged_first": "无标签在前",
|
||||
"newest_first": "最新在前",
|
||||
"oldest_first": "最早在前",
|
||||
"a_to_z": "A → Z",
|
||||
"z_to_a": "Z → A",
|
||||
"largest_first": "最大在前",
|
||||
"smallest_first": "最小在前"
|
||||
},
|
||||
"add_level": "添加层级",
|
||||
"remove_level": "移除层级",
|
||||
"move_up": "上移",
|
||||
"move_down": "下移",
|
||||
"tiebreak_note": "最多三个层级;后续层级用于打破平局。“最新在前”始终最后应用。",
|
||||
"scope": {
|
||||
"label": "应用于所有文件夹",
|
||||
"description": "关闭时,排序仅应用于收件箱,其他文件夹保持按时间排序。"
|
||||
},
|
||||
"unsupported_note": "您的邮件服务器不支持按已读状态、星标或标签排序。这些层级将被忽略,列表保持按时间排序。"
|
||||
},
|
||||
"title": "外观",
|
||||
"description": "自定义邮箱的外观与风格",
|
||||
"theme": {
|
||||
|
||||
+33
-10
@@ -2,8 +2,9 @@ import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
|
||||
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import type { SortLevel } from "@/lib/message-list-order";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { resolveThreadRoute } from "@/lib/thread-routing";
|
||||
@@ -67,6 +68,9 @@ interface EmailStore {
|
||||
retainedInViewIds: Set<string>;
|
||||
hasMoreEmails: boolean; // Track if more emails are available to load
|
||||
totalEmails: number; // Total number of emails in the current mailbox/query
|
||||
// The configured message-list order the current folder view was fetched
|
||||
// with (#718); empty for chronological. Thread grouping mirrors it.
|
||||
listOrder: SortLevel[];
|
||||
isPushConnected: boolean; // Track if push notifications are connected
|
||||
lastPushUpdate: number | null; // Timestamp of last push update
|
||||
newEmailNotification: Email | null; // New email notification for toast
|
||||
@@ -1064,6 +1068,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
lastSelectedEmailId: null,
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
listOrder: [],
|
||||
isPushConnected: false,
|
||||
lastPushUpdate: null,
|
||||
newEmailNotification: null,
|
||||
@@ -1362,12 +1367,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
? await advancedSearchUnifiedEmails(built, unifiedRole!, (mb) => buildJMAPFilter(searchQuery, searchFilters, mb), emailsPerPage, 0)
|
||||
: searchQuery
|
||||
? await searchUnifiedEmails(built, unifiedRole!, searchQuery, emailsPerPage, 0)
|
||||
: await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0));
|
||||
: await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0, getMessageListOrderFor(unifiedRole)));
|
||||
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
set({
|
||||
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
listOrder: crossView || hasFilters || searchQuery ? [] : getMessageListOrderFor(unifiedRole),
|
||||
threadEmailsCache: new Map(),
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null,
|
||||
@@ -1400,6 +1406,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
? null
|
||||
: useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
|
||||
// The configured list order (#718). A tag view spans folders and has no
|
||||
// role, so it only follows the order under the "all folders" scope.
|
||||
const order = getMessageListOrderFor(selectedKeyword ? null : mailbox?.role);
|
||||
|
||||
// When filtering by tag, omit the mailbox constraint so emails across
|
||||
// all folders that carry the tag are returned.
|
||||
const result = await effectiveClient.getEmails(
|
||||
@@ -1410,12 +1420,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
keywordFilter,
|
||||
true,
|
||||
categoryFilter ?? undefined,
|
||||
order,
|
||||
);
|
||||
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
set({
|
||||
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
listOrder: order,
|
||||
// Clear thread caches since the email list was fully replaced
|
||||
threadEmailsCache: new Map(),
|
||||
expandedThreadIds: new Set(),
|
||||
@@ -1506,7 +1518,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
)
|
||||
: searchQuery
|
||||
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
|
||||
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
|
||||
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position, getMessageListOrderFor(unifiedRole));
|
||||
const currentEmails = get().emails;
|
||||
const existingIds = new Set(currentEmails.map(e => e.id));
|
||||
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
|
||||
@@ -1587,6 +1599,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedKeyword ? `$label:${selectedKeyword}` : undefined,
|
||||
true,
|
||||
categoryFilter ?? undefined,
|
||||
getMessageListOrderFor(selectedKeyword ? null : mailbox?.role),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3330,7 +3343,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
? await advancedSearchUnifiedEmails(built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0)
|
||||
: searchQuery
|
||||
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, 0)
|
||||
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, 0);
|
||||
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, 0, getMessageListOrderFor(unifiedRole));
|
||||
unifiedErrors = result.errors;
|
||||
} else {
|
||||
// Single real mailbox (own or shared): query by its JMAP id. Refresh the
|
||||
@@ -3349,7 +3362,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const scopeAccountId = scopeMailbox?.isShared ? scopeMailbox.accountId : undefined;
|
||||
result = await effectiveClient.advancedSearchEmails(filter, scopeAccountId, emailsPerPage, 0);
|
||||
} else {
|
||||
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true);
|
||||
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true, undefined, getMessageListOrderFor(mailbox?.role));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3360,9 +3373,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Without these guards the toast/sound also fires when sending,
|
||||
// saving drafts, or moving/deleting the top message in any mailbox,
|
||||
// because all of those change the first-email id of the current view.
|
||||
// Pinned mails sit above the date order, so the newest mail is the
|
||||
// first NON-pinned entry (a just-arrived mail cannot be pinned yet).
|
||||
const newFirst = result.emails.find(e => !e.keywords?.['$pinned']) ?? result.emails[0];
|
||||
// Pinned mails sit above the date order, and a configured list order
|
||||
// (#718, e.g. starred first) can put older mail on top too, so the
|
||||
// candidate is the newest non-pinned mail on the page rather than its
|
||||
// first entry (a just-arrived mail cannot be pinned yet). Deleting the
|
||||
// top message shifts in an OLDER one, which never becomes the newest.
|
||||
const newFirst = result.emails
|
||||
.filter(e => !e.keywords?.['$pinned'])
|
||||
.reduce<Email | undefined>(
|
||||
(newest, e) => (!newest || new Date(e.receivedAt).getTime() > new Date(newest.receivedAt).getTime() ? e : newest),
|
||||
undefined,
|
||||
) ?? result.emails[0];
|
||||
if (
|
||||
newFirst &&
|
||||
mailbox?.role === 'inbox' &&
|
||||
@@ -4016,11 +4037,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
});
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
|
||||
const order = getMessageListOrderFor(role);
|
||||
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0, order);
|
||||
set({
|
||||
emails: result.emails,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
listOrder: order,
|
||||
isLoading: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
@@ -4044,7 +4067,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const position = emails.length;
|
||||
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
|
||||
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position, getMessageListOrderFor(unifiedRole));
|
||||
|
||||
const currentEmails = get().emails;
|
||||
const existingIds = new Set(currentEmails.map(e => e.id));
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { generateAccountId } from '@/lib/account-utils';
|
||||
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
|
||||
import {
|
||||
DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
isValidSubAddressDelimiter,
|
||||
@@ -303,6 +304,10 @@ interface SettingsState {
|
||||
fontSize: FontSize;
|
||||
density: Density;
|
||||
animationsEnabled: boolean;
|
||||
// Message-list ordering (#718): prioritised sort levels mapped onto the JMAP
|
||||
// Email/query sort; empty = chronological. Scope: Inbox only or every folder.
|
||||
messageListOrder: SortLevel[];
|
||||
messageListOrderScope: MessageListOrderScope;
|
||||
|
||||
// Language & Region
|
||||
dateFormat: DateFormat;
|
||||
@@ -537,6 +542,8 @@ const DEFAULT_SETTINGS = {
|
||||
fontSize: 'medium' as FontSize,
|
||||
density: 'regular' as Density,
|
||||
animationsEnabled: true,
|
||||
messageListOrder: [] as SortLevel[],
|
||||
messageListOrderScope: 'inbox' as MessageListOrderScope,
|
||||
|
||||
// Language & Region
|
||||
dateFormat: 'smart' as DateFormat,
|
||||
@@ -765,6 +772,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
fontSize: state.fontSize,
|
||||
density: state.density,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
messageListOrder: state.messageListOrder,
|
||||
messageListOrderScope: state.messageListOrderScope,
|
||||
dateFormat: state.dateFormat,
|
||||
dateLocale: state.dateLocale,
|
||||
timeFormat: state.timeFormat,
|
||||
@@ -909,6 +918,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
|
||||
return;
|
||||
}
|
||||
// The list order is sent to the server as a sort array, so an
|
||||
// unknown criterion from a newer/older client must never get
|
||||
// through (an unsupportedSort refusal empties the folder).
|
||||
if (key === 'messageListOrder') {
|
||||
set({ messageListOrder: sanitizeSortLevels(settings[key]) });
|
||||
return;
|
||||
}
|
||||
if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') {
|
||||
return;
|
||||
}
|
||||
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
@@ -1161,6 +1180,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (!isPlainRecord(state.preferredIdentityIds)) {
|
||||
state.preferredIdentityIds = {};
|
||||
}
|
||||
state.messageListOrder = sanitizeSortLevels(state.messageListOrder);
|
||||
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
|
||||
state.messageListOrderScope = 'inbox';
|
||||
}
|
||||
applyFontSize(state.fontSize);
|
||||
applyDensity(state.density);
|
||||
applyAnimations(state.animationsEnabled);
|
||||
@@ -1363,3 +1386,14 @@ if (typeof window !== 'undefined') {
|
||||
// templates.
|
||||
void import('./template-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* The message-list order to request for a folder with the given role (#718):
|
||||
* the configured levels when they apply to every folder or this is the Inbox,
|
||||
* chronological otherwise. Pass null for views without a folder role (tag
|
||||
* views, search) - those only pick the order up under the "all folders" scope.
|
||||
*/
|
||||
export function getMessageListOrderFor(role: string | null | undefined): SortLevel[] {
|
||||
const { messageListOrder, messageListOrderScope } = useSettingsStore.getState();
|
||||
return orderForMailbox(messageListOrder, messageListOrderScope, role);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user