feat: split the Mountain View inbox into Unread and Everything else, as Gmail's unread first

This commit is contained in:
Linus Rath
2026-10-25 13:13:46 +01:00
parent e31f55064e
commit f7a4087c91
39 changed files with 1083 additions and 39 deletions
@@ -0,0 +1,108 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailList } from '../email-list';
import { useEmailStore } from '@/stores/email-store';
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Email } from '@/lib/jmap/types';
// Mountain View's "Unread first" inbox: the Unread section's conversations
// first, each section under its header, and a folded section's rows gone.
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/stores/auth-store', async () => {
const { create } = await import('zustand');
return {
useAuthStore: create(() => ({ client: null, identities: [], username: null, activeAccountId: 'acct-1' })),
};
});
vi.mock('@/stores/account-store', () => {
const state = { getAccountById: () => undefined };
return {
useAccountStore: Object.assign(
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
{ getState: () => state },
),
};
});
const mail = (id: string, time: string, read: boolean): Email => ({
id,
threadId: `thread-${id}`,
mailboxIds: { inbox: true },
keywords: read ? { $seen: true } : {},
size: 1000,
receivedAt: `2026-09-30T${time}:00Z`,
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: `Mail ${id}`,
hasAttachment: false,
});
// The store's list: the section's rows first, then the read mail by date.
const EMAILS = [
mail('U2', '12:00', false),
mail('U1', '09:00', false),
mail('R2', '16:00', true),
mail('R1', '10:00', true),
];
const initialEmailState = useEmailStore.getState();
const initialTabsState = useMessageListTabsStore.getState();
const initialSettings = useSettingsStore.getState();
beforeEach(() => {
// Room in the list for every item, or the virtual list draws none of them.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600);
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600);
useEmailStore.setState(initialEmailState, true);
useEmailStore.setState({
selectedMailbox: 'inbox',
mailboxes: [{ id: 'inbox', role: 'inbox' } as never],
emails: EMAILS,
totalEmails: 2,
unreadSection: { mailboxId: 'inbox', ids: new Set(['U2', 'U1']), total: 7 },
});
useMessageListTabsStore.setState(initialTabsState, true);
useSettingsStore.setState({
...initialSettings,
interfaceLayout: 'mountain-view',
mvInboxType: 'unread-first',
mvUnreadSectionCollapsed: false,
mvEverythingElseCollapsed: false,
}, true);
});
afterEach(() => { vi.restoreAllMocks(); });
const rowKeys = () => useEmailStore.getState().listRows?.map((row) => row.key);
describe('the "Unread first" inbox list', () => {
it('puts the Unread section first, under its header, then everything else', () => {
const { container } = render(<EmailList emails={EMAILS} />);
expect(rowKeys()).toEqual(['U2', 'U1', 'R2', 'R1']);
const headers = [...container.querySelectorAll('[data-mv-list-section]')].map((h) => h.textContent);
expect(headers).toHaveLength(2);
expect(headers[0]).toContain('sections.unread');
expect(headers[1]).toContain('sections.everything_else');
});
it('folds a section away from its header', () => {
const { container } = render(<EmailList emails={EMAILS} />);
const unreadToggle = container.querySelector('[data-mv-list-section] button') as HTMLButtonElement;
act(() => unreadToggle.click());
expect(useSettingsStore.getState().mvUnreadSectionCollapsed).toBe(true);
expect(rowKeys()).toEqual(['R2', 'R1']);
expect(container.querySelectorAll('[data-mv-list-section]')).toHaveLength(2);
});
it('is one list sorted by date in the default inbox', () => {
useSettingsStore.setState({ mvInboxType: 'default' });
const { container } = render(<EmailList emails={EMAILS} />);
expect(rowKeys()).toEqual(['R2', 'U2', 'R1', 'U1']);
expect(container.querySelector('[data-mv-list-section]')).toBeNull();
});
});
+111 -17
View File
@@ -39,6 +39,9 @@ import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { normalizeAddress } from "@/lib/filters/quick-rules";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { unreadSectionRowCount } from "@/lib/unread-first";
import { MvListSectionHeader } from "./mv-list-section-header";
interface EmailListProps {
emails: Email[];
@@ -83,6 +86,15 @@ interface EmailListProps {
hideBatchToolbar?: boolean;
}
/**
* What the virtual list draws: a row, or in the "Unread first" inbox a
* section's header and the line an empty Unread section shows.
*/
type ListItem =
| { kind: 'row'; thread: ThreadGroup }
| { kind: 'header'; section: 'unread' | 'rest' }
| { kind: 'unread-empty' };
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 480;
@@ -241,12 +253,59 @@ export function EmailList({
const activeAccountId = useAuthStore((state) => state.activeAccountId);
const activeTabId = useMessageListTabsStore((state) => state.activeTabId);
const threadGroups = useMemo(() => {
const allThreadGroups = useMemo(() => {
const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder;
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups, listOrder, listHold?.keywords);
}, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, searchQuery, crossView, searchFilters, listHold]);
// Mountain View's "Unread first" inbox: the Unread section's conversations
// go first, under a header each section has, and a folded section's rows
// leave the list. A conversation with a message in the section is the
// section's.
const unreadSection = useUnreadFirstSection();
const unreadCollapsed = useSettingsStore((state) => state.mvUnreadSectionCollapsed);
const restCollapsed = useSettingsStore((state) => state.mvEverythingElseCollapsed);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const sections = useMemo(() => {
if (!unreadSection) return null;
const unread: ThreadGroup[] = [];
const rest: ThreadGroup[] = [];
for (const group of allThreadGroups) {
(group.emails.some((email) => unreadSection.ids.has(email.id)) ? unread : rest).push(group);
}
return { unread, rest };
}, [allThreadGroups, unreadSection]);
const threadGroups = useMemo(() => (
sections
? [...(unreadCollapsed ? [] : sections.unread), ...(restCollapsed ? [] : sections.rest)]
: allThreadGroups
), [sections, unreadCollapsed, restCollapsed, allThreadGroups]);
const { items, itemOfThread } = useMemo(() => {
const items: ListItem[] = [];
const itemOfThread: number[] = [];
const addRows = (groups: ThreadGroup[]) => {
for (const thread of groups) {
itemOfThread.push(items.length);
items.push({ kind: 'row', thread });
}
};
if (!sections) {
addRows(threadGroups);
return { items, itemOfThread };
}
items.push({ kind: 'header', section: 'unread' });
if (!unreadCollapsed) {
if (sections.unread.length === 0) items.push({ kind: 'unread-empty' });
addRows(sections.unread);
}
items.push({ kind: 'header', section: 'rest' });
if (!restCollapsed) addRows(sections.rest);
return { items, itemOfThread };
}, [sections, threadGroups, unreadCollapsed, restCollapsed]);
const sectionRowCount = useMemo(() => unreadSectionRowCount(emails, unreadSection), [emails, unreadSection]);
const totalEmails = useEmailStore((state) => state.totalEmails);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
/**
* The row the menu was opened on, as the list currently has it. The menu holds
@@ -388,13 +447,15 @@ export function EmailList({
// The virtualizer's guesses follow the rows' shape (see useListRowShape).
const estimateSize = useCallback((index: number) => {
const item = items[index];
if (item?.kind === 'header' || item?.kind === 'unread-empty') return 48;
if (isFocusedMailLayout) {
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
}
// Rows the list has not measured yet are placed by this guess, and each
// one it gets wrong shifts the list when it is measured on the way back
// up after a jump down (scrollbar drag, End). So guess per row.
const latest = threadGroups[index]?.latestEmail;
const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined;
let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
if (showPreview && density !== 'extra-compact') {
// A mail without a preview draws a one-line "No preview available",
@@ -406,17 +467,22 @@ export function EmailList({
const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest);
if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28;
return size;
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, threadGroups, onOpenAttachment, loadAttachments]);
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]);
// Stable per list, so the virtualizer does not rebuild every row's
// measurement on each scroll render.
const getItemKey = useCallback(
(index: number) => threadGroups[index]?.threadKey ?? String(index),
[threadGroups],
(index: number) => {
const item = items[index];
if (item?.kind === 'row') return item.thread.threadKey;
if (item?.kind === 'header') return `section:${item.section}`;
return item ? 'section:unread-empty' : String(index);
},
[items],
);
const virtualizer = useVirtualizer({
count: threadGroups.length,
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 5,
@@ -677,9 +743,11 @@ export function EmailList({
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// A folded "Everything else" has nothing below to load into.
const loadsMore = !(sections && restCollapsed);
useEffect(() => {
if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) {
if (lastVirtualItemIndex === undefined || !loadsMore) return;
if (lastVirtualItemIndex >= items.length - 5) {
// Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
@@ -690,7 +758,7 @@ export function EmailList({
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
}, [lastVirtualItemIndex, items.length, handleLoadMore, loadsMore]);
// Scroll to the thread group containing the selected email
useEffect(() => {
@@ -703,11 +771,12 @@ export function EmailList({
// A row clicked in the list is already on screen; scrollToIndex would
// still measure and schedule a scroll. Judge from the virtualizer's own
// numbers, which cost no layout read.
const item = virtualizer.measurementsCache[index];
const itemIndex = itemOfThread[index];
const item = virtualizer.measurementsCache[itemIndex];
const top = virtualizer.scrollOffset;
const height = virtualizer.scrollRect?.height;
if (item && top !== null && height !== undefined && item.start >= top && item.end <= top + height) return;
virtualizer.scrollToIndex(index, { align: 'auto' });
virtualizer.scrollToIndex(itemIndex, { align: 'auto' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmailId]);
@@ -716,7 +785,7 @@ export function EmailList({
useEffect(() => {
if (!selectionFocusKey) return;
const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectionFocusKey));
if (index >= 0) virtualizer.scrollToIndex(index, { align: 'auto' });
if (index >= 0) virtualizer.scrollToIndex(itemOfThread[index], { align: 'auto' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionFocusKey]);
@@ -951,7 +1020,8 @@ export function EmailList({
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const thread = threadGroups[virtualItem.index];
const listItem = items[virtualItem.index];
const thread = listItem?.kind === 'row' ? listItem.thread : null;
return (
<div
key={virtualItem.key}
@@ -966,7 +1036,31 @@ export function EmailList({
width: '100%',
}}
>
<MemoizedThreadListItem
{listItem?.kind === 'header' && unreadSection && (
listItem.section === 'unread' ? (
<MvListSectionHeader
title={t('sections.unread')}
collapsed={unreadCollapsed}
onToggle={() => updateSetting('mvUnreadSectionCollapsed', !unreadCollapsed)}
shown={sectionRowCount}
total={unreadSection.total}
/>
) : (
<MvListSectionHeader
title={t('sections.everything_else')}
collapsed={restCollapsed}
onToggle={() => updateSetting('mvEverythingElseCollapsed', !restCollapsed)}
shown={emails.length - sectionRowCount}
total={totalEmails}
/>
)
)}
{listItem?.kind === 'unread-empty' && (
<p className="flex h-12 items-center border-b border-border px-6 text-sm text-muted-foreground">
{t('sections.unread_empty')}
</p>
)}
{thread && <MemoizedThreadListItem
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadKey)}
selectedEmailId={selectedEmailId}
@@ -988,13 +1082,13 @@ export function EmailList({
onUndoSpam={rowHandlers.onUndoSpam}
onOpenAttachment={rowHandlers.onOpenAttachment}
loadAttachments={loadAttachments}
/>
/>}
</div>
);
})}
</div>
<div className="py-4 flex justify-center">
{loadsMore && <div className="py-4 flex justify-center">
{footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
@@ -1006,7 +1100,7 @@ export function EmailList({
{t('no_more_emails')}
</div>
)}
</div>
</div>}
</>
)}
</div>
@@ -0,0 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp } from "@/components/icons";
interface MvListSectionHeaderProps {
title: string;
collapsed: boolean;
onToggle: () => void;
/** Rows the section shows and how many it has in all, for the range readout. */
shown: number;
total: number;
}
/** A section of Mountain View's "Unread first" inbox: "Unread" or "Everything else", folding as in Gmail. */
export function MvListSectionHeader({ title, collapsed, onToggle, shown, total }: MvListSectionHeaderProps) {
const t = useTranslations("email_list");
const Chevron = collapsed ? ChevronDown : ChevronUp;
return (
<div className="flex h-12 items-center border-b border-border px-2" data-mv-list-section="">
<button
type="button"
onClick={onToggle}
aria-expanded={!collapsed}
className="flex min-w-0 items-center gap-3 rounded-full py-1.5 ps-2 pe-3 text-base font-medium text-foreground hover:bg-muted"
>
<Chevron className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{title}</span>
</button>
<div className="flex-1" />
{shown > 0 && (
<span className="shrink-0 pe-3 text-xs tabular-nums text-muted-foreground">
{t("conversations_range", { from: 1, to: shown, total: Math.max(total, shown) })}
</span>
)}
</div>
);
}
+4 -1
View File
@@ -36,6 +36,7 @@ import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
interface MvListToolbarProps {
/** Loaded conversation count, for the right-hand range readout. */
@@ -217,6 +218,8 @@ export function MvListToolbar({
// Optimistic updates and demo fixtures can briefly leave the server total
// behind the rows already loaded. Never render an impossible "23 of 22".
const displayedTotal = Math.max(totalCount ?? loadedCount, loadedCount);
// The "Unread first" inbox gives each section its own readout instead.
const sectioned = !!useUnreadFirstSection();
const currentRole = mailboxes.find((mb) => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
@@ -659,7 +662,7 @@ export function MvListToolbar({
<div className="flex-1 min-w-0" />
{loadedCount > 0 && (
{loadedCount > 0 && !sectioned && (
<span className="text-xs text-muted-foreground tabular-nums pe-2 shrink-0">
{/* The range readout. The list grows as it scrolls rather than
paging, so there are no previous/next arrows to go with it. */}
+21 -1
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useEffectiveMailLayout } from '@/hooks/use-effective-mail-layout';
import { useMediaQuery } from '@/hooks/use-media-query';
import type { ArchiveMode, HoverAction, SwipeAction } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MvInboxType, SwipeAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
@@ -68,6 +68,7 @@ export function MessageListSettings() {
listColumns,
mailLayout,
interfaceLayout,
mvInboxType,
tintListRowsByTag,
senderFavicons,
showAvatarsInJunk,
@@ -177,6 +178,25 @@ export function MessageListSettings() {
</SettingItem>
)}
{/* Gmail's inbox types; "Unread first" puts the newest unread mail in a section above the rest. */}
{mountainView && (
<SettingItem label={t('inbox_type.label')} description={t('inbox_type.description')}>
<Select
value={mvInboxType}
onChange={(value) => {
updateSetting('mvInboxType', value as MvInboxType);
const client = useAuthStore.getState().client;
const store = useEmailStore.getState();
if (client && store.selectedMailbox) void store.fetchEmails(client, store.selectedMailbox, { background: true });
}}
options={[
{ value: 'default', label: t('inbox_type.default') },
{ value: 'unread-first', label: t('inbox_type.unread_first') },
]}
/>
</SettingItem>
)}
<MessageListOrderSettings />
<SettingItem label={tAppearance('tint_list_rows.label')} description={tAppearance('tint_list_rows.description')}>
+24
View File
@@ -0,0 +1,24 @@
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { isFilterEmpty } from '@/lib/jmap/search-utils';
import { isUnreadFirstView, type UnreadSection } from '@/lib/unread-first';
/**
* The Unread section of Mountain View's "Unread first" inbox, when the list
* on screen is that inbox and its section is loaded; null otherwise.
*/
export function useUnreadFirstSection(): UnreadSection | null {
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const mvInboxType = useSettingsStore((state) => state.mvInboxType);
return useEmailStore((state) => {
const section = state.unreadSection;
if (!section || section.mailboxId !== state.selectedMailbox || state.isScheduledView) return null;
const active = isUnreadFirstView({
interfaceLayout,
mvInboxType,
mailboxRole: state.mailboxes.find((mailbox) => mailbox.id === state.selectedMailbox)?.role,
otherView: !!state.selectedKeyword || !!state.searchQuery || !isFilterEmpty(state.searchFilters) || state.isUnifiedView,
});
return active ? section : null;
});
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import { andFilters, isUnreadFirstView, mergeUnreadSection, withoutReached, type UnreadSection } from '../unread-first';
const mail = (id: string, threadId = `thread-${id}`) => ({ id, threadId, keywords: {} }) as Email;
const section = (...ids: string[]): UnreadSection => ({ mailboxId: 'inbox', ids: new Set(ids), total: ids.length });
describe('mergeUnreadSection', () => {
it('puts the fresh section first and drops the rows that left it', () => {
const list = [mail('u1'), mail('u2'), mail('r1'), mail('r2')];
const merged = mergeUnreadSection(list, section('u1', 'u2'), [mail('u3'), mail('u1')], null);
expect(merged.emails.map((e) => e.id)).toEqual(['u3', 'u1', 'r1', 'r2']);
expect([...merged.ids]).toEqual(['u3', 'u1']);
});
it('keeps the open conversation\'s message that left the section', () => {
const list = [mail('u1'), mail('u2', 'open'), mail('r1')];
const merged = mergeUnreadSection(list, section('u1', 'u2'), [mail('u1')], 'open');
expect(merged.emails.map((e) => e.id)).toEqual(['u1', 'u2', 'r1']);
expect(merged.ids.has('u2')).toBe(true);
});
it('takes a row of the main list the section now has into the section', () => {
const merged = mergeUnreadSection([mail('r1'), mail('r2')], section(), [mail('r2')], null);
expect(merged.emails.map((e) => e.id)).toEqual(['r2', 'r1']);
});
});
describe('withoutReached', () => {
it('returns the same section when a page reached none of its rows', () => {
const before = section('u1');
expect(withoutReached(before, ['r1'])).toBe(before);
expect([...withoutReached(before, ['u1'])!.ids]).toEqual([]);
});
});
describe('andFilters', () => {
it('ANDs two fragments and passes a lone one through', () => {
expect(andFilters(null, { a: 1 })).toEqual({ a: 1 });
expect(andFilters({ a: 1 }, undefined)).toEqual({ a: 1 });
expect(andFilters({ a: 1 }, { b: 2 })).toEqual({ operator: 'AND', conditions: [{ a: 1 }, { b: 2 }] });
});
});
describe('isUnreadFirstView', () => {
const view = { interfaceLayout: 'mountain-view', mvInboxType: 'unread-first', mailboxRole: 'inbox', otherView: false } as const;
it('is Mountain View\'s inbox with the inbox type set, and nothing else', () => {
expect(isUnreadFirstView(view)).toBe(true);
expect(isUnreadFirstView({ ...view, interfaceLayout: 'default' })).toBe(false);
expect(isUnreadFirstView({ ...view, mvInboxType: 'default' })).toBe(false);
expect(isUnreadFirstView({ ...view, mailboxRole: 'archive' })).toBe(false);
expect(isUnreadFirstView({ ...view, otherView: true })).toBe(false);
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { Email } from '@/lib/jmap/types';
import type { InterfaceLayout, MvInboxType } from '@/stores/settings-store';
import { threadKeyFor } from '@/lib/thread-utils';
/**
* Mountain View's "Unread first" inbox, as Gmail has it: the newest unread
* mail in a section of its own above "Everything else", which is the rest
* of the inbox, read mail only.
*
* Both sections live in the one loaded list (`emails` in the email store),
* since every action on a message looks it up there. The section's rows go
* first and are named by `UnreadSection.ids`; they are not part of the
* server order of the main query, so paging leaves them out of its offset.
*/
/** How many unread messages the section shows, as in Gmail. */
export const UNREAD_SECTION_SIZE = 25;
/** The main query of the view: what is not in the Unread section. */
export const READ_FILTER: Record<string, unknown> = { hasKeyword: '$seen' };
export const UNREAD_FILTER: Record<string, unknown> = { notKeyword: '$seen' };
export interface UnreadSection {
/** The folder the section was loaded for. */
mailboxId: string;
/** The rows of `emails` that are the section's. */
ids: Set<string>;
/** How many unread messages the folder has in all. */
total: number;
}
export interface UnreadFirstView {
interfaceLayout: InterfaceLayout;
mvInboxType: MvInboxType;
mailboxRole?: string | null;
/** A search, tag, unified or cross view shows something else than the folder. */
otherView: boolean;
}
export function isUnreadFirstView(view: UnreadFirstView): boolean {
return view.interfaceLayout === 'mountain-view'
&& view.mvInboxType === 'unread-first'
&& view.mailboxRole === 'inbox'
&& !view.otherView;
}
/** Two filter fragments ANDed; either may be missing. */
export function andFilters(
a: Record<string, unknown> | null | undefined,
b: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!a) return b ?? null;
if (!b) return a;
return { operator: 'AND', conditions: [a, b] };
}
/** How many rows of the list are the section's, which the main query's offset must not count. */
export function unreadSectionRowCount(emails: Email[], section: UnreadSection | null): number {
if (!section || section.ids.size === 0) return 0;
let count = 0;
for (const email of emails) if (section.ids.has(email.id)) count++;
return count;
}
/** The section without the rows a page of the main query has just reached. */
export function withoutReached(section: UnreadSection | null, reached: Iterable<string>): UnreadSection | null {
if (!section) return section;
let ids: Set<string> | null = null;
for (const id of reached) {
if (!section.ids.has(id)) continue;
ids ??= new Set(section.ids);
ids.delete(id);
}
return ids ? { ...section, ids } : section;
}
/**
* Put a freshly loaded section into the list. Rows of the old section that
* are gone from it leave the list, except the open conversation's: every
* action looks its message up in the list, so "mark as unread" on the mail
* just read must still find it.
*/
export function mergeUnreadSection(
emails: Email[],
previous: UnreadSection | null,
fresh: Email[],
openThreadKey: string | null,
): { emails: Email[]; ids: Set<string> } {
const freshIds = new Set(fresh.map((e) => e.id));
const ids = new Set(freshIds);
const kept: Email[] = [];
const rest: Email[] = [];
for (const email of emails) {
if (freshIds.has(email.id)) continue;
if (previous?.ids.has(email.id)) {
if (openThreadKey && threadKeyFor(email) === openThreadKey) {
kept.push(email);
ids.add(email.id);
}
continue;
}
rest.push(email);
}
return { emails: [...fresh, ...kept, ...rest], ids };
}
/** A section put back together from a list remembered or restored without one: its unread rows. */
export function sectionFromUnreadRows(emails: Email[], mailboxId: string, total?: number): UnreadSection {
const ids = new Set(emails.filter((e) => !e.keywords?.$seen).map((e) => e.id));
return { mailboxId, ids, total: Math.max(total ?? 0, ids.size) };
}
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "إظهار على الجوال"
},
"email_list": {
"sections": {
"unread": "غير مقروءة",
"everything_else": "كل الرسائل الأخرى",
"unread_empty": "لقد قرأت كل الرسائل في صندوق الوارد."
},
"columns": {
"from": "من",
"to": "إلى",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "نوع صندوق الوارد",
"description": "يعرض خيار \"غير المقروءة أولاً\" أحدث 25 رسالة غير مقروءة في قسم فوق باقي صندوق الوارد",
"default": "افتراضي",
"unread_first": "غير المقروءة أولاً"
},
"clear_search_on_folder_change": {
"label": "مسح البحث عند تبديل المجلدات",
"description": "النقر على مجلد (البريد الوارد، المرسل…) يمسح البحث النشط ويعرض ذلك المجلد. عند الإيقاف، يبقى البحث ويُعاد تشغيله في المجلد الذي تنتقل إليه."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Mostra al mòbil"
},
"email_list": {
"sections": {
"unread": "No llegits",
"everything_else": "Tota la resta",
"unread_empty": "Has llegit tots els missatges de la safata d'entrada."
},
"columns": {
"from": "De",
"to": "Per a",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Tipus de safata d'entrada",
"description": "«Primer els no llegits» mostra els 25 missatges no llegits més recents en una secció a sobre de la resta de la safata",
"default": "Per defecte",
"unread_first": "Primer els no llegits"
},
"clear_search_on_folder_change": {
"label": "Esborra la cerca en canviar de carpeta",
"description": "En clicar una carpeta (Safata d'entrada, Enviats…) s'esborra la cerca activa i es mostra aquesta carpeta. Si està desactivat, la cerca es manté i es torna a executar a la carpeta triada."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Zobrazit na mobilu"
},
"email_list": {
"sections": {
"unread": "Nepřečtené",
"everything_else": "Vše ostatní",
"unread_empty": "Přečetli jste všechny zprávy v doručené poště."
},
"columns": {
"from": "Od",
"to": "Komu",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Typ doručené pošty",
"description": "„Nejdříve nepřečtené“ zobrazí 25 nejnovějších nepřečtených zpráv v samostatné sekci nad zbytkem doručené pošty",
"default": "Výchozí",
"unread_first": "Nejdříve nepřečtené"
},
"clear_search_on_folder_change": {
"label": "Vymazat hledání při přepnutí složky",
"description": "Kliknutí na složku (Doručená pošta, Odeslaná…) vymaže aktivní hledání a zobrazí složku. Když je vypnuto, hledání zůstává a spustí se znovu ve zvolené složce."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Vis på mobil"
},
"email_list": {
"sections": {
"unread": "Ulæste",
"everything_else": "Alt andet",
"unread_empty": "Du har læst alle beskeder i indbakken."
},
"columns": {
"from": "Fra",
"to": "Til",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Indbakketype",
"description": "\"Ulæste først\" viser dine 25 nyeste ulæste beskeder i en sektion over resten af indbakken",
"default": "Standard",
"unread_first": "Ulæste først"
},
"clear_search_on_folder_change": {
"label": "Ryd søgningen ved skift af mappe",
"description": "Klik på en mappe (Indbakke, Sendt …) rydder den aktive søgning og viser mappen. Når det er slået fra, bevares søgningen og køres igen i den valgte mappe."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Auf Mobilgerät anzeigen"
},
"email_list": {
"sections": {
"unread": "Ungelesen",
"everything_else": "Alles andere",
"unread_empty": "Du hast alle Nachrichten im Posteingang gelesen."
},
"columns": {
"from": "Von",
"to": "An",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Posteingangstyp",
"description": "„Ungelesene zuerst“ zeigt deine 25 neuesten ungelesenen Nachrichten in einem eigenen Abschnitt über dem restlichen Posteingang",
"default": "Standard",
"unread_first": "Ungelesene zuerst"
},
"clear_search_on_folder_change": {
"label": "Suche beim Ordnerwechsel löschen",
"description": "Ein Klick auf einen Ordner (Posteingang, Gesendet …) löscht die aktive Suche und zeigt den Ordner. Ist die Option aus, bleibt die Suche bestehen und wird im neuen Ordner erneut ausgeführt."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Show on Mobile"
},
"email_list": {
"sections": {
"unread": "Unread",
"everything_else": "Everything else",
"unread_empty": "You've read all the messages in your inbox."
},
"columns": {
"from": "From",
"to": "To",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Inbox type",
"description": "Unread first shows your 25 newest unread messages in a section above the rest of the inbox",
"default": "Default",
"unread_first": "Unread first"
},
"clear_search_on_folder_change": {
"label": "Clear search when switching folders",
"description": "Clicking a folder (Inbox, Sent, …) clears the active search and browses that folder. When off, the search stays applied and re-runs in the folder you switch to."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Mostrar en móvil"
},
"email_list": {
"sections": {
"unread": "No leídos",
"everything_else": "Todo lo demás",
"unread_empty": "Has leído todos los mensajes de la bandeja de entrada."
},
"columns": {
"from": "De",
"to": "Para",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Tipo de bandeja de entrada",
"description": "«No leídos primero» muestra tus 25 mensajes no leídos más recientes en una sección encima del resto de la bandeja",
"default": "Predeterminado",
"unread_first": "No leídos primero"
},
"clear_search_on_folder_change": {
"label": "Borrar la búsqueda al cambiar de carpeta",
"description": "Al hacer clic en una carpeta (Bandeja de entrada, Enviados…) se borra la búsqueda activa y se muestra esa carpeta. Si está desactivado, la búsqueda se mantiene y se vuelve a ejecutar en la carpeta a la que cambies."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Näita mobiilis"
},
"email_list": {
"sections": {
"unread": "Lugemata",
"everything_else": "Kõik muu",
"unread_empty": "Oled kõik postkasti kirjad läbi lugenud."
},
"columns": {
"from": "Saatja",
"to": "Saaja",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Postkasti tüüp",
"description": "„Lugemata ees“ näitab 25 uusimat lugemata kirja eraldi jaotises ülejäänud postkasti kohal",
"default": "Vaikimisi",
"unread_first": "Lugemata ees"
},
"clear_search_on_folder_change": {
"label": "Tühjenda otsing kausta vahetamisel",
"description": "Kaustale (Saabunud, Saadetud, …) klõpsamine tühjendab aktiivse otsingu ja avab selle kausta. Kui see on välja lülitatud, jääb otsing kehtima ja käivitub uuesti kaustas, millele lülitud."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "نمایش در موبایل"
},
"email_list": {
"sections": {
"unread": "خوانده‌نشده",
"everything_else": "بقیه",
"unread_empty": "همه پیام‌های صندوق ورودی را خوانده‌اید."
},
"columns": {
"from": "از",
"to": "به",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "نوع صندوق ورودی",
"description": "«اول خوانده‌نشده‌ها» ۲۵ پیام خوانده‌نشده جدیدتان را در بخشی بالای بقیه صندوق ورودی نشان می‌دهد",
"default": "پیش‌فرض",
"unread_first": "اول خوانده‌نشده‌ها"
},
"clear_search_on_folder_change": {
"label": "پاک کردن جستجو هنگام تغییر پوشه",
"description": "کلیک روی یک پوشه (صندوق ورودی، ارسال‌شده…) جستجوی فعال را پاک می‌کند و آن پوشه را نشان می‌دهد. وقتی خاموش باشد، جستجو باقی می‌ماند و در پوشهٔ جدید دوباره اجرا می‌شود."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Afficher sur mobile"
},
"email_list": {
"sections": {
"unread": "Non lus",
"everything_else": "Tout le reste",
"unread_empty": "Vous avez lu tous les messages de votre boîte de réception."
},
"columns": {
"from": "De",
"to": "À",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Type de boîte de réception",
"description": "« Non lus en premier » affiche vos 25 messages non lus les plus récents dans une section au-dessus du reste de la boîte",
"default": "Par défaut",
"unread_first": "Non lus en premier"
},
"clear_search_on_folder_change": {
"label": "Effacer la recherche lors du changement de dossier",
"description": "Cliquer sur un dossier (Boîte de réception, Envoyés…) efface la recherche active et affiche ce dossier. Désactivé, la recherche reste appliquée et est relancée dans le dossier choisi."
+11
View File
@@ -209,6 +209,11 @@
"show_on_mobile": "הצג בנייד"
},
"email_list": {
"sections": {
"unread": "לא נקראו",
"everything_else": "כל השאר",
"unread_empty": "קראת את כל ההודעות בתיבת הדואר הנכנס."
},
"columns": {
"from": "מאת",
"to": "אל",
@@ -1377,6 +1382,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "סוג תיבת הדואר הנכנס",
"description": "\"לא נקראו תחילה\" מציג את 25 ההודעות החדשות ביותר שלא נקראו במקטע מעל שאר התיבה",
"default": "ברירת מחדל",
"unread_first": "לא נקראו תחילה"
},
"clear_search_on_folder_change": {
"label": "נקה את החיפוש בעת מעבר בין תיקיות",
"description": "לחיצה על תיקייה (דואר נכנס, נשלח…) מנקה את החיפוש הפעיל ומציגה את התיקייה. כשהאפשרות כבויה, החיפוש נשאר ומופעל מחדש בתיקייה שאליה עברת."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Megjelenítés mobilon"
},
"email_list": {
"sections": {
"unread": "Olvasatlan",
"everything_else": "Minden más",
"unread_empty": "A beérkezett üzenetek mappában minden üzenetet elolvastál."
},
"columns": {
"from": "Feladó",
"to": "Címzett",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Beérkezett üzenetek típusa",
"description": "Az „Olvasatlanok elöl” a 25 legújabb olvasatlan üzenetet külön szakaszban mutatja a többi üzenet felett",
"default": "Alapértelmezett",
"unread_first": "Olvasatlanok elöl"
},
"clear_search_on_folder_change": {
"label": "Keresés törlése mappaváltáskor",
"description": "Egy mappára kattintva (Beérkezett, Elküldött…) törlődik az aktív keresés, és a mappa jelenik meg. Kikapcsolva a keresés megmarad, és újra lefut a választott mappában."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Mostra su mobile"
},
"email_list": {
"sections": {
"unread": "Da leggere",
"everything_else": "Tutto il resto",
"unread_empty": "Hai letto tutti i messaggi della posta in arrivo."
},
"columns": {
"from": "Da",
"to": "A",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Tipo di posta in arrivo",
"description": "\"Prima i non letti\" mostra i 25 messaggi non letti più recenti in una sezione sopra il resto della posta in arrivo",
"default": "Predefinito",
"unread_first": "Prima i non letti"
},
"clear_search_on_folder_change": {
"label": "Cancella la ricerca quando cambi cartella",
"description": "Facendo clic su una cartella (Posta in arrivo, Inviati…) la ricerca attiva viene cancellata e si apre quella cartella. Se disattivato, la ricerca resta attiva e viene rieseguita nella cartella scelta."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "モバイルで表示"
},
"email_list": {
"sections": {
"unread": "未読",
"everything_else": "その他すべて",
"unread_empty": "受信トレイのメッセージをすべて読みました。"
},
"columns": {
"from": "差出人",
"to": "宛先",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "受信トレイの種類",
"description": "「未読を先頭」では、未読の最新25件を受信トレイの他のメールの上のセクションに表示します",
"default": "デフォルト",
"unread_first": "未読を先頭"
},
"clear_search_on_folder_change": {
"label": "フォルダー切り替え時に検索をクリア",
"description": "フォルダー(受信トレイ、送信済みなど)をクリックすると、有効な検索をクリアしてそのフォルダーを表示します。オフの場合、検索は保持され、切り替え先のフォルダーで再実行されます。"
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "모바일에서 보기"
},
"email_list": {
"sections": {
"unread": "읽지 않음",
"everything_else": "기타 모든 메일",
"unread_empty": "받은편지함의 메일을 모두 읽었습니다."
},
"columns": {
"from": "보낸 사람",
"to": "받는 사람",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "받은편지함 유형",
"description": "'읽지 않은 메일 먼저'는 읽지 않은 최신 메일 25개를 받은편지함 나머지 위의 섹션에 표시합니다",
"default": "기본",
"unread_first": "읽지 않은 메일 먼저"
},
"clear_search_on_folder_change": {
"label": "폴더 전환 시 검색 지우기",
"description": "폴더(받은편지함, 보낸편지함 등)를 클릭하면 활성 검색을 지우고 해당 폴더를 표시합니다. 꺼져 있으면 검색이 유지되고 이동한 폴더에서 다시 실행됩니다."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Rādīt mobilajā ierīcē"
},
"email_list": {
"sections": {
"unread": "Nelasītie",
"everything_else": "Viss pārējais",
"unread_empty": "Jūs esat izlasījis visus iesūtnes ziņojumus."
},
"columns": {
"from": "No",
"to": "Kam",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Iesūtnes veids",
"description": "“Vispirms nelasītie” rāda 25 jaunākos nelasītos ziņojumus atsevišķā sadaļā virs pārējās iesūtnes",
"default": "Noklusējums",
"unread_first": "Vispirms nelasītie"
},
"clear_search_on_folder_change": {
"label": "Notīrīt meklēšanu, mainot mapi",
"description": "Noklikšķinot uz mapes (Iesūtne, Nosūtītie…), aktīvā meklēšana tiek notīrīta un tiek atvērta šī mape. Kad izslēgts, meklēšana paliek un tiek atkārtota izvēlētajā mapē."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Мобайл дээр харуулах"
},
"email_list": {
"sections": {
"unread": "Уншаагүй",
"everything_else": "Бусад бүх",
"unread_empty": "Та ирсэн хайрцгийн бүх захидлыг уншсан байна."
},
"columns": {
"from": "Илгээгч",
"to": "Хүлээн авагч",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Ирсэн хайрцгийн төрөл",
"description": "\"Уншаагүйг эхэнд\" нь уншаагүй хамгийн сүүлийн 25 захидлыг бусад захидлын дээрх хэсэгт харуулна",
"default": "Үндсэн",
"unread_first": "Уншаагүйг эхэнд"
},
"clear_search_on_folder_change": {
"label": "Хавтас солих үед хайлтыг арилгах",
"description": "Хавтас дээр дарахад (Ирсэн, Илгээсэн…) идэвхтэй хайлт арилж, тухайн хавтас нээгдэнэ. Унтраасан үед хайлт хадгалагдаж, шилжсэн хавтаст дахин ажиллана."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Vis på mobil"
},
"email_list": {
"sections": {
"unread": "Uleste",
"everything_else": "Alt annet",
"unread_empty": "Du har lest alle meldingene i innboksen."
},
"columns": {
"from": "Fra",
"to": "Til",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Innbokstype",
"description": "«Uleste først» viser de 25 nyeste uleste meldingene i en egen del over resten av innboksen",
"default": "Standard",
"unread_first": "Uleste først"
},
"clear_search_on_folder_change": {
"label": "Tøm søket ved bytte av mappe",
"description": "Å klikke på en mappe (Innboks, Sendt …) tømmer det aktive søket og viser mappen. Når det er av, beholdes søket og kjøres på nytt i mappen du bytter til."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Weergeven op mobiel"
},
"email_list": {
"sections": {
"unread": "Ongelezen",
"everything_else": "Al het andere",
"unread_empty": "Je hebt alle berichten in je inbox gelezen."
},
"columns": {
"from": "Van",
"to": "Aan",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Type inbox",
"description": "'Ongelezen eerst' toont je 25 nieuwste ongelezen berichten in een sectie boven de rest van de inbox",
"default": "Standaard",
"unread_first": "Ongelezen eerst"
},
"clear_search_on_folder_change": {
"label": "Zoekopdracht wissen bij wisselen van map",
"description": "Op een map klikken (Postvak IN, Verzonden…) wist de actieve zoekopdracht en toont die map. Uitgeschakeld blijft de zoekopdracht staan en wordt opnieuw uitgevoerd in de gekozen map."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Pokaż na telefonie"
},
"email_list": {
"sections": {
"unread": "Nieprzeczytane",
"everything_else": "Wszystkie pozostałe",
"unread_empty": "Przeczytano wszystkie wiadomości w odebranych."
},
"columns": {
"from": "Od",
"to": "Do",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Typ skrzynki odbiorczej",
"description": "„Najpierw nieprzeczytane” pokazuje 25 najnowszych nieprzeczytanych wiadomości w sekcji nad resztą skrzynki",
"default": "Domyślny",
"unread_first": "Najpierw nieprzeczytane"
},
"clear_search_on_folder_change": {
"label": "Czyść wyszukiwanie przy zmianie folderu",
"description": "Kliknięcie folderu (Odebrane, Wysłane…) czyści aktywne wyszukiwanie i otwiera ten folder. Gdy wyłączone, wyszukiwanie pozostaje i jest ponawiane w wybranym folderze."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Mostrar no celular"
},
"email_list": {
"sections": {
"unread": "Não lidas",
"everything_else": "Todo o resto",
"unread_empty": "Leu todas as mensagens da caixa de entrada."
},
"columns": {
"from": "De",
"to": "Para",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Tipo de caixa de entrada",
"description": "\"Não lidas primeiro\" mostra as suas 25 mensagens não lidas mais recentes numa secção acima do resto da caixa",
"default": "Predefinido",
"unread_first": "Não lidas primeiro"
},
"clear_search_on_folder_change": {
"label": "Limpar a pesquisa ao mudar de pasta",
"description": "Clicar numa pasta (Caixa de entrada, Enviados…) limpa a pesquisa ativa e mostra essa pasta. Se desativado, a pesquisa mantém-se e volta a ser executada na pasta para a qual mudar."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Afișare pe mobil"
},
"email_list": {
"sections": {
"unread": "Necitite",
"everything_else": "Toate celelalte",
"unread_empty": "Ai citit toate mesajele din inbox."
},
"columns": {
"from": "De la",
"to": "Către",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Tipul inboxului",
"description": "„Întâi necitite” afișează cele mai noi 25 de mesaje necitite într-o secțiune deasupra restului inboxului",
"default": "Implicit",
"unread_first": "Întâi necitite"
},
"clear_search_on_folder_change": {
"label": "Șterge căutarea la schimbarea folderului",
"description": "Un clic pe un folder (Inbox, Trimise…) șterge căutarea activă și afișează acel folder. Dezactivat, căutarea rămâne și se reia în folderul ales."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Показывать на мобильном"
},
"email_list": {
"sections": {
"unread": "Непрочитанные",
"everything_else": "Все остальное",
"unread_empty": "Вы прочитали все письма во входящих."
},
"columns": {
"from": "От",
"to": "Кому",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Тип папки «Входящие»",
"description": "«Сначала непрочитанные» показывает 25 последних непрочитанных писем в отдельном разделе над остальными входящими",
"default": "По умолчанию",
"unread_first": "Сначала непрочитанные"
},
"clear_search_on_folder_change": {
"label": "Очищать поиск при переходе между папками",
"description": "Щелчок по папке («Входящие», «Отправленные»…) очищает активный поиск и открывает эту папку. Если выключено, поиск сохраняется и выполняется заново в выбранной папке."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Zobraziť na mobile"
},
"email_list": {
"sections": {
"unread": "Neprečítané",
"everything_else": "Všetko ostatné",
"unread_empty": "Prečítali ste všetky správy v doručenej pošte."
},
"columns": {
"from": "Od",
"to": "Komu",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Typ doručenej pošty",
"description": "„Najprv neprečítané“ zobrazí 25 najnovších neprečítaných správ v sekcii nad zvyškom doručenej pošty",
"default": "Predvolené",
"unread_first": "Najprv neprečítané"
},
"clear_search_on_folder_change": {
"label": "Vymazať hľadanie pri prepnutí priečinka",
"description": "Kliknutie na priečinok (Doručená pošta, Odoslaná…) vymaže aktívne hľadanie a zobrazí priečinok. Keď je vypnuté, hľadanie zostáva a spustí sa znova vo zvolenom priečinku."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Mobilde Göster"
},
"email_list": {
"sections": {
"unread": "Okunmamış",
"everything_else": "Diğer her şey",
"unread_empty": "Gelen kutusundaki tüm iletileri okudunuz."
},
"columns": {
"from": "Kimden",
"to": "Kime",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Gelen kutusu türü",
"description": "\"Önce okunmamışlar\", en yeni 25 okunmamış iletinizi gelen kutusunun geri kalanının üstündeki bir bölümde gösterir",
"default": "Varsayılan",
"unread_first": "Önce okunmamışlar"
},
"clear_search_on_folder_change": {
"label": "Klasör değiştirirken aramayı temizle",
"description": "Bir klasöre tıklamak (Gelen Kutusu, Gönderilenler…) etkin aramayı temizler ve o klasörü gösterir. Kapalıyken arama korunur ve geçtiğiniz klasörde yeniden çalıştırılır."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "Показати на мобільному"
},
"email_list": {
"sections": {
"unread": "Непрочитані",
"everything_else": "Усе інше",
"unread_empty": "Ви прочитали всі листи у вхідних."
},
"columns": {
"from": "Від",
"to": "Кому",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "Тип вхідних",
"description": "«Спершу непрочитані» показує 25 найновіших непрочитаних листів в окремому розділі над рештою вхідних",
"default": "Типово",
"unread_first": "Спершу непрочитані"
},
"clear_search_on_folder_change": {
"label": "Очищати пошук під час перемикання тек",
"description": "Клацання теки («Вхідні», «Надіслані»…) очищає активний пошук і відкриває цю теку. Якщо вимкнено, пошук лишається й повторюється у вибраній теці."
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "在行動裝置上顯示"
},
"email_list": {
"sections": {
"unread": "未讀",
"everything_else": "其他所有郵件",
"unread_empty": "您已讀完收件匣中的所有郵件。"
},
"columns": {
"from": "寄件者",
"to": "收件者",
@@ -1415,6 +1420,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "收件匣類型",
"description": "「未讀優先」會在收件匣其餘郵件上方的獨立區塊中顯示最新的 25 封未讀郵件",
"default": "預設",
"unread_first": "未讀優先"
},
"clear_search_on_folder_change": {
"label": "切換資料夾時清除搜尋",
"description": "點擊資料夾(收件匣、寄件備份等)會清除目前的搜尋並瀏覽該資料夾。關閉時,搜尋會保留並在切換到的資料夾中重新執行。"
+11
View File
@@ -244,6 +244,11 @@
"show_on_mobile": "在手机上显示"
},
"email_list": {
"sections": {
"unread": "未读",
"everything_else": "其他所有邮件",
"unread_empty": "您已读完收件箱中的所有邮件。"
},
"columns": {
"from": "发件人",
"to": "收件人",
@@ -1412,6 +1417,12 @@
}
},
"email_behavior": {
"inbox_type": {
"label": "收件箱类型",
"description": "“未读优先”会在收件箱其余邮件上方的单独区域中显示最新的 25 封未读邮件",
"default": "默认",
"unread_first": "未读优先"
},
"clear_search_on_folder_change": {
"label": "切换文件夹时清除搜索",
"description": "点击文件夹(收件箱、已发送等)会清除当前搜索并浏览该文件夹。关闭时,搜索会保留并在切换到的文件夹中重新运行。"
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useSettingsStore } from '../settings-store';
import { useAuthStore } from '../auth-store';
import { useMessageListTabsStore } from '../message-list-tabs-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Mountain View's "Unread first" inbox: the newest unread mail in a section
// of its own above "Everything else", which is the inbox's read mail.
const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox;
function email(id: string, minute: number, read: boolean): Email {
return {
id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: read ? { $seen: true } : {},
subject: id, receivedAt: new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(),
from: [{ email: 'sender@example.com' }], to: [], preview: '', size: 1, hasAttachment: false,
} as Email;
}
/** A server holding `rows` (newest first) that honours the $seen filters, limits and positions. */
function server(rows: Email[]) {
const getEmails = vi.fn(async (...args: unknown[]) => {
const [, , limit, position, , , filter] = args as [string, string, number, number, unknown, unknown, Record<string, unknown> | undefined];
const matching = rows.filter((e) => {
if (filter?.hasKeyword === '$seen') return !!e.keywords.$seen;
if (filter?.notKeyword === '$seen') return !e.keywords.$seen;
return true;
});
const page = matching.slice(position, position + limit).map((e) => ({ ...e, keywords: { ...e.keywords } }));
return { emails: page, total: matching.length, hasMore: position + limit < matching.length, state: 's' };
});
return {
getEmails,
getThreads: vi.fn(async () => []),
getAccountId: () => 'me',
} as unknown as IJMAPClient & { getEmails: typeof getEmails };
}
// 3 unread and 30 read messages, newest first, the unread ones in between.
function inboxRows(): Email[] {
const rows: Email[] = [];
for (let i = 0; i < 33; i++) rows.push(email(`m${i}`, 100 - i, ![1, 5, 9].includes(i)));
return rows;
}
const ids = () => useEmailStore.getState().emails.map((e) => e.id);
describe('the "Unread first" inbox', () => {
beforeEach(() => {
useAuthStore.setState(useAuthStore.getInitialState());
useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState());
useSettingsStore.setState({
emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
notificationMutes: { accounts: [], mailboxes: {} },
interfaceLayout: 'mountain-view', mvInboxType: 'unread-first',
});
useEmailStore.setState({
...useEmailStore.getInitialState(),
selectedMailbox: 'inbox', mailboxes: [inbox],
});
});
it('loads the unread mail into its section ahead of the read mail', async () => {
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'inbox');
const state = useEmailStore.getState();
expect(ids().slice(0, 3)).toEqual(['m1', 'm5', 'm9']);
expect([...state.unreadSection!.ids]).toEqual(['m1', 'm5', 'm9']);
expect(state.unreadSection!.total).toBe(3);
// Everything else is the read mail, a page of it.
expect(ids().slice(3)).toHaveLength(25);
expect(state.emails.slice(3).every((e) => e.keywords.$seen)).toBe(true);
expect(state.totalEmails).toBe(30);
expect(state.hasMoreEmails).toBe(true);
});
it('pages the read mail from where it got to, not counting the section', async () => {
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'inbox');
c.getEmails.mockClear();
await useEmailStore.getState().loadMoreEmails(c);
expect(c.getEmails.mock.calls[0][3]).toBe(25);
expect(ids()).toHaveLength(33);
expect(new Set(ids()).size).toBe(33);
expect(useEmailStore.getState().hasMoreEmails).toBe(false);
});
it('keeps the section through a refresh, and still has more to load', async () => {
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'inbox');
await useEmailStore.getState().refreshCurrentMailbox(c);
expect(ids().slice(0, 3)).toEqual(['m1', 'm5', 'm9']);
expect(ids()).toHaveLength(28);
expect(useEmailStore.getState().hasMoreEmails).toBe(true);
});
it('keeps the section rows while the section cannot be loaded again', async () => {
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'inbox');
const plain = c.getEmails.getMockImplementation()!;
c.getEmails.mockImplementation(async (...args: unknown[]) => {
if ((args[6] as Record<string, unknown> | undefined)?.notKeyword) throw new Error('offline');
return plain(...args);
});
await useEmailStore.getState().refreshCurrentMailbox(c);
expect(ids().slice(0, 3)).toEqual(['m1', 'm5', 'm9']);
expect(ids()).toHaveLength(28);
});
it('moves a message read meanwhile from the section to everything else', async () => {
const rows = inboxRows();
const c = server(rows);
await useEmailStore.getState().fetchEmails(c, 'inbox');
rows[5].keywords = { $seen: true };
await useEmailStore.getState().refreshCurrentMailbox(c);
const state = useEmailStore.getState();
expect([...state.unreadSection!.ids]).toEqual(['m1', 'm9']);
expect(ids().slice(0, 2)).toEqual(['m1', 'm9']);
expect(ids().slice(2)).toContain('m5');
expect(ids().filter((id) => id === 'm5')).toHaveLength(1);
});
it('announces new mail, which only the section has', async () => {
const rows = inboxRows();
const c = server(rows);
await useEmailStore.getState().fetchEmails(c, 'inbox');
rows.unshift(email('new', 200, false));
await useEmailStore.getState().refreshCurrentMailbox(c);
expect(ids()[0]).toBe('new');
expect(useEmailStore.getState().newEmailNotification?.id).toBe('new');
});
it('is the plain inbox with the default inbox type', async () => {
useSettingsStore.setState({ mvInboxType: 'default' });
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'inbox');
expect(c.getEmails).toHaveBeenCalledTimes(1);
expect(c.getEmails.mock.calls[0][6]).toBeUndefined();
expect(useEmailStore.getState().unreadSection).toBeNull();
expect(ids().slice(0, 3)).toEqual(['m0', 'm1', 'm2']);
});
it('only applies to the inbox', async () => {
const archive = { id: 'archive', role: 'archive', isShared: false } as Mailbox;
useEmailStore.setState({ mailboxes: [inbox, archive], selectedMailbox: 'archive' });
const c = server(inboxRows());
await useEmailStore.getState().fetchEmails(c, 'archive');
expect(c.getEmails).toHaveBeenCalledTimes(1);
expect(c.getEmails.mock.calls[0][6]).toBeUndefined();
expect(useEmailStore.getState().unreadSection).toBeNull();
});
});
+127 -20
View File
@@ -30,6 +30,10 @@ import { useAccountStore } from "@/stores/account-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import type { StorageQuota } from "@/lib/quota";
import { isArchiveActionEnabled } from "@/lib/archive-gate";
import {
READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection,
sectionFromUnreadRows, unreadSectionRowCount, withoutReached, type UnreadSection,
} from "@/lib/unread-first";
type ScheduledSubmissionMetadata = {
submissionId: string;
@@ -158,6 +162,9 @@ interface EmailStore {
// category tab, unified/cross view). A push is then resolved with
// Email/changes against this state instead of re-querying the first page.
emailListSync: EmailListSync | null;
// Mountain View's "Unread first" inbox (lib/unread-first.ts): which rows of
// `emails` are the Unread section's. Null in every other view.
unreadSection: UnreadSection | null;
// Mailbox collection state per JMAP account the sidebar list was last
// synced at, so a Mailbox push can be resolved with Mailbox/changes.
mailboxSyncStates: Record<string, string>;
@@ -721,6 +728,66 @@ let emailDeltaQueue: Promise<void> = Promise.resolve();
type StoreGet = () => EmailStore;
type StoreSet = (partial: Partial<EmailStore> | ((state: EmailStore) => Partial<EmailStore>)) => void;
/** Whether this folder view is Mountain View's "Unread first" inbox. */
function unreadFirstFor(
mailbox: Mailbox | undefined,
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
): boolean {
const { interfaceLayout, mvInboxType } = useSettingsStore.getState();
return isUnreadFirstView({
interfaceLayout,
mvInboxType,
mailboxRole: mailbox?.role,
otherView: !!s.selectedKeyword || !!s.searchQuery || !isFilterEmpty(s.searchFilters) || s.isUnifiedView,
});
}
/**
* What a folder's query ANDs in: the open category tab's filter, and read
* mail only in the "Unread first" inbox, whose unread mail is the section's.
*/
function folderListFilter(
mailbox: Mailbox | undefined,
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
): Record<string, unknown> | null {
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
return andFilters(categoryFilter, unreadFirstFor(mailbox, s) ? READ_FILTER : null);
}
/** The newest unread mail of the folder, for the "Unread first" inbox. */
async function loadUnreadSection(client: IJMAPClient, mailbox: Mailbox, get: StoreGet): Promise<{ emails: Email[]; total: number }> {
const effectiveClient = resolveActionClient(client);
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox.role);
const result = await effectiveClient.getEmails(
mailbox.originalId || mailbox.id,
accountId,
UNREAD_SECTION_SIZE,
0,
undefined,
true,
andFilters(categoryFilter, UNREAD_FILTER) ?? undefined,
[],
);
const emails = await emailHooks.onEmailsFetched.transform(result.emails);
return { emails: annotateScheduledEmails(emails, get().scheduledSubmissionByEmailId), total: result.total };
}
function notifyNewInboxMail(client: IJMAPClient, mailbox: Mailbox, email: Email, get: StoreGet) {
// A muted Inbox (or account) stays quiet in the app too (#1169).
const accountId = mailbox.accountId || resolveActionClient(client).getAccountId();
const muted = isMailboxMuted(
useSettingsStore.getState().notificationMutes, accountId, mailbox.originalId || mailbox.id,
);
if (!muted) get().handleNewEmailNotification(email);
}
function applyUnreadSection(get: StoreGet, set: StoreSet, mailboxId: string, loaded: { emails: Email[]; total: number }) {
const { emails, unreadSection, selectedEmail } = get();
const merged = mergeUnreadSection(emails, unreadSection, loaded.emails, selectedEmail ? threadKeyFor(selectedEmail) : null);
set({ emails: merged.emails, unreadSection: { mailboxId, ids: merged.ids, total: loaded.total } });
}
/**
* After a local read/star/tag change: re-query a folder list sorted on one of
* the changed keywords (#718). The change moves the row at once, possibly past
@@ -756,7 +823,7 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null,
}
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find((mb) => mb.id === sync.mailboxId);
if (useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role)) return false;
if (folderListFilter(mailbox, s)) return false;
const effectiveClient = resolveActionClient(client);
if (!effectiveClient.getEmailChanges) return false;
// The push carries the state we already hold (our own Email/set raised it,
@@ -2258,6 +2325,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isPushConnected: false,
lastPushUpdate: null,
emailListSync: null,
unreadSection: null,
mailboxSyncStates: {},
newEmailNotification: null,
@@ -2812,8 +2880,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
// Plugin-registered category tabs (Gmail-style) AND their resolved JMAP
// filter fragment into the mailbox view.
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
// filter fragment into the mailbox view, and the "Unread first" inbox
// leaves its unread mail to the section above the list.
const categoryFilter = folderListFilter(mailbox, get());
const unreadFirst = unreadFirstFor(mailbox, get());
const sectionLoad = unreadFirst && mailbox
? loadUnreadSection(client, mailbox, get).catch((error) => {
console.error('Failed to fetch the unread section:', error);
return null;
})
: null;
const showSection = async () => {
if (!sectionLoad) return;
const loaded = await sectionLoad;
if (loaded && isCurrentView()) applyUnreadSection(get, set, targetMailboxId, loaded);
};
// The configured list order (#718).
const order = getMessageListOrderFor(mailbox?.role);
@@ -2846,7 +2927,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
expandedThreadIds: new Set(),
isLoadingThread: null,
isLoading: false,
// The remembered page holds the section's rows too; they are its unread ones.
unreadSection: unreadFirst ? sectionFromUnreadRows(remembered.emails, targetMailboxId) : null,
});
} else if (!unreadFirst) {
set({ unreadSection: null });
}
// Until this folder's rows are on screen, edits to the list belong to
// whatever was showing before and must not be remembered under this key.
@@ -2869,6 +2954,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const upToDate = await applyEmailDeltaNow(client, null, get, set);
if (!isCurrentView()) return;
if (upToDate) {
await showSection();
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
// The boot snapshot carries no conversation sizes.
if (get().threadEmailCounts.size === 0) void get().fetchThreadEmailCounts(client);
@@ -2914,6 +3000,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isLoading: false
});
shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
await showSection();
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
// Only a server that did not answer the chained Thread/get needs a
// request of its own for the conversation sizes.
@@ -3107,12 +3194,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Category tabs (plugin-registered) must filter pagination the same
// way as the initial fetch or pages would mix categories.
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
const categoryFilter = folderListFilter(mailbox, get());
// Rows kept out of a keyword order (the open conversation, see
// refreshCurrentMailbox) are not part of the loaded head of the
// server order; counting them would skip as many rows.
// server order; counting them would skip as many rows. Neither are
// the rows of the Unread section.
const retained = get().retainedInViewIds;
const outOfOrder = emails.filter(e => retained.has(e.id)).length;
const outOfOrder = emails.filter(e => retained.has(e.id)).length
+ unreadSectionRowCount(emails, get().unreadSection);
result = await effectiveClient.getEmails(
jmapMailboxId,
accountId,
@@ -3123,11 +3212,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
categoryFilter ?? undefined,
getMessageListOrderFor(mailbox?.role),
);
// A kept row this page reached is back in order.
// A kept row this page reached is back in order, and so is a row
// of the Unread section that was read since.
const reached = new Set(result.emails.map(e => e.id));
if (outOfOrder > 0 && [...retained].some(id => reached.has(id))) {
set({ retainedInViewIds: new Set([...get().retainedInViewIds].filter(id => !reached.has(id))) });
}
const unreadSection = withoutReached(get().unreadSection, reached);
if (unreadSection !== get().unreadSection) set({ unreadSection });
}
// Use fresh state when merging to avoid overwriting concurrent updates
@@ -5687,14 +5779,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// so the list stays the head of the server order. The rows kept
// out of that order below are not part of the head.
if (orderKeywords(order).length > 0) {
const { emails: loaded, retainedInViewIds } = get();
const inOrder = loaded.filter(e => !retainedInViewIds.has(e.id)).length;
const { emails: loaded, retainedInViewIds, unreadSection } = get();
const inOrder = loaded.filter(e => !retainedInViewIds.has(e.id) && !unreadSection?.ids.has(e.id)).length;
windowSize = Math.min(Math.max(emailsPerPage, inOrder), effectiveClient.getMaxObjectsInGet());
keepOpenThread = true;
}
// The open category tab filters the refresh like the first load and
// pagination, or other tabs' mail would merge into the list (#1264).
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
const categoryFilter = folderListFilter(mailbox, get());
result = await effectiveClient.getEmails(jmapMailboxId, accountId, windowSize, 0, undefined, true, categoryFilter ?? undefined, order);
// This page is the new delta-sync baseline for the folder; the
// search / unified paths above cannot be delta-synced, and neither
@@ -5748,12 +5840,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
!currentEmails.some(e => e.id === newFirst.id) &&
(!keepOpenThread || insertedCount > 0)
) {
// A muted Inbox (or account) stays quiet in the app too (#1169).
const accountId = mailbox.accountId || resolveActionClient(client).getAccountId();
const muted = isMailboxMuted(
useSettingsStore.getState().notificationMutes, accountId, mailbox.originalId || mailbox.id,
);
if (!muted) get().handleNewEmailNotification(newFirst);
notifyNewInboxMail(client, mailbox, newFirst, get);
}
// Merge the refreshed first page with the existing loaded emails.
@@ -5779,8 +5866,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Rows kept out of a keyword order are not part of the re-queried head
// and are decided on below.
const previouslyRetained = get().retainedInViewIds;
const inOrderEmails = keepOpenThread
? currentEmails.filter(e => !previouslyRetained.has(e.id))
// Nor are the Unread section's; they go back in front below, until
// the section is loaded again. A row of it the fresh page has (read
// since) is the page's now.
const unreadSection = withoutReached(get().unreadSection, mergedIds);
const sectionRows = unreadSection ? currentEmails.filter(e => unreadSection.ids.has(e.id)) : [];
const inOrderEmails = (keepOpenThread || sectionRows.length > 0)
? currentEmails.filter(e => !(keepOpenThread && previouslyRetained.has(e.id)) && !unreadSection?.ids.has(e.id))
: currentEmails;
for (const email of inOrderEmails.slice(appendFromIndex)) {
@@ -5826,6 +5918,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// The fresh order has the held row where its new read/star/tag state
// sorts; put it back where the list shows it (the order EmailList uses).
merged = placeHeldRows(merged, searchQuery || crossView || hasFilters ? [] : get().listOrder, held);
if (sectionRows.length > 0) merged = [...sectionRows, ...merged];
// Check if anything actually changed to avoid unnecessary re-renders
const hasChanged =
@@ -5846,8 +5939,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// shows and mis-compute hasMore.
const effectiveTotal = (result.total || 0) + retainedAddedCount;
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < effectiveTotal;
// what we have loaded, using the fresh total from the server. The
// Unread section's rows are not part of that total.
const hasMore = merged.length - sectionRows.length < effectiveTotal;
// Invalidate thread email caches for threads whose composition changed
// so expanded threads pick up new/removed emails.
@@ -5896,6 +5990,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: newCache,
...(unifiedErrors !== undefined ? { unifiedErrors } : {}),
...(retainedInViewIds ? { retainedInViewIds } : {}),
unreadSection,
};
});
@@ -5923,6 +6018,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else if (retainedInViewIds) {
set({ retainedInViewIds });
}
// The Unread section is loaded again too; new mail lands there.
if (mailbox && !selectedKeyword && unreadFirstFor(mailbox, get())) {
const loaded = await loadUnreadSection(client, mailbox, get);
if (!isCurrentView()) return;
const listed = new Set(get().emails.map(e => e.id));
const newest = loaded.emails.find(e => !e.keywords?.['$pinned']);
if (newest && !listed.has(newest.id) && loaded.total > (get().unreadSection?.total ?? 0)) {
notifyNewInboxMail(client, mailbox, newest, get);
}
applyUnreadSection(get, set, selectedMailbox, loaded);
}
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
// Don't set error state for background refreshes to avoid disrupting the UI
+10
View File
@@ -239,6 +239,8 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
* docked compose window) that keeps the theme's icons, type and palette.
*/
export type InterfaceLayout = 'default' | 'mountain-view';
// Mountain View's inbox: Gmail's "Default" or "Unread first" (lib/unread-first.ts).
export type MvInboxType = 'default' | 'unread-first';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote';
@@ -481,6 +483,10 @@ interface SettingsState {
fontSize: FontSize;
density: Density;
interfaceLayout: InterfaceLayout;
mvInboxType: MvInboxType;
// The folded sections of the "Unread first" inbox; per device.
mvUnreadSectionCollapsed: boolean;
mvEverythingElseCollapsed: boolean;
animationsEnabled: boolean;
// Message-list ordering (#718): prioritised sort levels mapped onto the JMAP
// Email/query sort; empty = chronological. Scope: Inbox only or every folder.
@@ -788,6 +794,9 @@ const DEFAULT_SETTINGS = {
fontSize: 'medium' as FontSize,
density: 'regular' as Density,
interfaceLayout: 'default' as InterfaceLayout,
mvInboxType: 'unread-first' as MvInboxType,
mvUnreadSectionCollapsed: false,
mvEverythingElseCollapsed: false,
animationsEnabled: true,
messageListOrder: [] as SortLevel[],
messageListOrderScope: 'inbox' as MessageListOrderScope,
@@ -1159,6 +1168,7 @@ export const useSettingsStore = create<SettingsState>()(
fontSize: state.fontSize,
density: state.density,
interfaceLayout: state.interfaceLayout,
mvInboxType: state.mvInboxType,
animationsEnabled: state.animationsEnabled,
messageListOrder: state.messageListOrder,
messageListOrderScope: state.messageListOrderScope,