From 9ce7ac81724190c0a65f0b56e1fa36fd00a931eb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:06:37 +0100 Subject: [PATCH 1/5] feat: show unread per account in the switcher and count every account on the app badge and tray The default layout's switcher listed accounts without their unread, so an existing test asserted that (account-switcher-mountain-view, 'unread-free'); it now expects each account's count. The idle-outside-Pro hook test now uses one account, since a second connected account loads in every layout. --- components/__tests__/favicon-badge.test.tsx | 47 +++++++++++ components/favicon-badge.tsx | 25 +++++- .../account-switcher-mountain-view.test.tsx | 10 ++- .../account-switcher-unread.test.tsx | 82 +++++++++++++++++++ components/layout/account-switcher.tsx | 40 ++++++--- components/layout/navigation-rail.tsx | 4 +- components/settings/notification-settings.tsx | 15 ++++ .../use-pro-multi-account-mailboxes.test.tsx | 39 ++++++++- hooks/use-pro-multi-account-mailboxes.ts | 48 +++++++++-- lib/account-utils.ts | 16 ++++ lib/plugin-sandbox/host-api-app.ts | 2 +- lib/settings-schema.ts | 1 + lib/settings-search.ts | 1 + locales/ar/common.json | 7 ++ locales/ca/common.json | 7 ++ locales/cs/common.json | 7 ++ locales/da/common.json | 7 ++ locales/de/common.json | 7 ++ locales/en/common.json | 7 ++ locales/es/common.json | 7 ++ locales/et/common.json | 7 ++ locales/fa/common.json | 7 ++ locales/fr/common.json | 7 ++ locales/he/common.json | 7 ++ locales/hu/common.json | 7 ++ locales/it/common.json | 7 ++ locales/ja/common.json | 7 ++ locales/ko/common.json | 7 ++ locales/lv/common.json | 7 ++ locales/mn/common.json | 7 ++ locales/nb/common.json | 7 ++ locales/nl/common.json | 7 ++ locales/pl/common.json | 7 ++ locales/pt/common.json | 7 ++ locales/ro/common.json | 7 ++ locales/ru/common.json | 7 ++ locales/sk/common.json | 7 ++ locales/th/common.json | 7 ++ locales/tr/common.json | 7 ++ locales/uk/common.json | 7 ++ locales/vi/common.json | 7 ++ locales/zh-TW/common.json | 7 ++ locales/zh/common.json | 7 ++ stores/settings-store.ts | 3 + 44 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 components/layout/__tests__/account-switcher-unread.test.tsx diff --git a/components/__tests__/favicon-badge.test.tsx b/components/__tests__/favicon-badge.test.tsx index 651ee5578..dcf8cdab4 100644 --- a/components/__tests__/favicon-badge.test.tsx +++ b/components/__tests__/favicon-badge.test.tsx @@ -5,6 +5,8 @@ import { useFaviconBadge } from '@/hooks/use-favicon-badge'; import { useAppBadge } from '@/hooks/use-app-badge'; import { useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; import type { Mailbox } from '@/lib/jmap/types'; vi.mock('@/hooks/use-favicon-badge', () => ({ @@ -96,6 +98,51 @@ describe('FaviconBadge', () => { expect(useAppBadgeMock).toHaveBeenCalledWith(4, true); }); + // Usability test 2026-11-04, mail-6: with two accounts the badge counted + // only the one on screen (11 instead of 31). + describe('with several accounts', () => { + const accounts = (ids: string[], connected = true) => + ids.map((id) => ({ id, label: id, email: id, username: id, isConnected: connected })); + + beforeEach(() => { + useAccountStore.setState({ accounts: accounts(['a', 'b', 'c']) } as never); + useAuthStore.setState({ activeAccountId: 'a' } as never); + useEmailStore.setState({ + mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 11 })], + accountMailboxes: { + // The active login's cached copy is older; `mailboxes` is the live one. + a: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 2 })], + b: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 20 }), mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 50 })], + }, + }); + }); + + afterEach(() => { + useAccountStore.setState({ accounts: [] } as never); + useEmailStore.setState({ accountMailboxes: {} }); + useSettingsStore.setState({ unreadBadgeAllAccounts: true }); + }); + + it('adds up the inbox of every signed-in account', () => { + render(); + // c's folders are not loaded yet and add nothing. + expect(useAppBadgeMock).toHaveBeenLastCalledWith(31, true); + expect(useFaviconBadgeMock).toHaveBeenLastCalledWith(31, true); + }); + + it('counts only the open account when the setting is off', () => { + useSettingsStore.setState({ unreadBadgeAllAccounts: false }); + render(); + expect(useAppBadgeMock).toHaveBeenLastCalledWith(11, true); + }); + + it('leaves out an account that is signed out', () => { + useAccountStore.setState({ accounts: [...accounts(['a']), ...accounts(['b'], false)] } as never); + render(); + expect(useAppBadgeMock).toHaveBeenLastCalledWith(11, true); + }); + }); + it('badges zero when there is no inbox yet', () => { useEmailStore.setState({ mailboxes: [] }); diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx index 976de641d..1bc199925 100644 --- a/components/favicon-badge.tsx +++ b/components/favicon-badge.tsx @@ -4,6 +4,10 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useFaviconBadge } from "@/hooks/use-favicon-badge"; import { useAppBadge } from "@/hooks/use-app-badge"; +import { useShallow } from "zustand/react/shallow"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { totalInboxUnread } from "@/lib/account-inbox-unread"; /** * Badges the browser-tab favicon, and — where supported — the installed PWA's @@ -34,9 +38,26 @@ export function FaviconBadge() { (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0, ); const enabled = useSettingsStore((s) => s.faviconUnreadBadge); + const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts); + // With several accounts the badge counts the inbox of each (on by default): + // new mail in an account that is not on screen still shows on the icon and + // in the desktop tray. The other accounts' folder lists are what the + // account switcher reads (useProMultiAccountMailboxes); one not loaded yet + // adds nothing. + const othersUnread = useOtherAccountsUnread(allAccounts); + const count = inboxUnread + othersUnread; - useFaviconBadge(inboxUnread, enabled); - useAppBadge(inboxUnread, enabled); + useFaviconBadge(count, enabled); + useAppBadge(count, enabled); return null; } + +function useOtherAccountsUnread(enabled: boolean): number { + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const otherIds = useAccountStore( + useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])), + ); + const accountMailboxes = useEmailStore((s) => s.accountMailboxes); + return totalInboxUnread(otherIds, accountMailboxes); +} diff --git a/components/layout/__tests__/account-switcher-mountain-view.test.tsx b/components/layout/__tests__/account-switcher-mountain-view.test.tsx index 5a099dc24..a404219f0 100644 --- a/components/layout/__tests__/account-switcher-mountain-view.test.tsx +++ b/components/layout/__tests__/account-switcher-mountain-view.test.tsx @@ -119,13 +119,17 @@ describe('AccountSwitcher under Mountain View', () => { expect(screen.queryByTestId('all-inboxes')).toBeNull(); }); - it('leaves the default layout listing every account, unread-free', () => { + // With several accounts the default layout shows the unread of each too + // (usability test 2026-11-04, mail-6): before, it listed them without. + it('leaves the default layout listing every account, each with its unread', () => { useSettingsStore.setState({ interfaceLayout: 'default' } as never); render(); openPopover(); - expect(screen.getAllByTestId('account-option')).toHaveLength(2); + const options = screen.getAllByTestId('account-option'); + expect(options).toHaveLength(2); expect(screen.queryByTestId('all-inboxes')).toBeNull(); - expect(screen.queryByText('5')).toBeNull(); + expect(within(options[0]).getByText('3')).toBeInTheDocument(); + expect(within(options[1]).getByText('5')).toBeInTheDocument(); }); }); diff --git a/components/layout/__tests__/account-switcher-unread.test.tsx b/components/layout/__tests__/account-switcher-unread.test.tsx new file mode 100644 index 000000000..f3d315f5d --- /dev/null +++ b/components/layout/__tests__/account-switcher-unread.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// Usability test 2026-11-04, mail-6: with two accounts the new mail in the +// other one was nowhere to be seen. The switcher now lists the unread of every +// account, and the avatar carries a dot while another account has unread mail. + +const { AccountSwitcher } = await import('../account-switcher'); +const { useAccountStore } = await import('@/stores/account-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +const inbox = (unread: number): Mailbox[] => + [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 40, unreadEmails: unread }] as unknown as Mailbox[]; + +function account(id: string, overrides: Record = {}) { + return { + id, + label: `${id}@example.com`, + email: `${id}@example.com`, + username: `${id}@example.com`, + displayName: 'Robin Kessler', + serverUrl: 'https://mail.example.com', + avatarColor: '#336699', + cookieSlot: 0, + isConnected: true, + isDefault: id === 'work', + ...overrides, + }; +} + +describe('AccountSwitcher unread per account', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account('work'), account('home')] } as never); + useAuthStore.setState({ activeAccountId: 'work' } as never); + useEmailStore.setState({ mailboxes: inbox(11), accountMailboxes: { home: inbox(20) } } as never); + useSettingsStore.setState({ interfaceLayout: 'default' } as never); + }); + + it('lists the unread of each account in the rail menu', () => { + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + + const [work, home] = screen.getAllByTestId('account-option'); + expect(work).toHaveAttribute('data-account-id', 'work'); + expect(within(work).getByText('11')).toBeInTheDocument(); + expect(within(home).getByText('20')).toBeInTheDocument(); + }); + + it('puts a dot on the avatar and says why while another account has unread mail', () => { + render(); + const button = screen.getByTestId('account-switcher'); + expect(within(button).getByTestId('other-accounts-unread')).toBeInTheDocument(); + expect(button.getAttribute('aria-label')).toContain('others'); + }); + + it('shows no dot when the other accounts are read', () => { + useEmailStore.setState({ accountMailboxes: { home: inbox(0) } } as never); + render(); + expect(screen.queryByTestId('other-accounts-unread')).toBeNull(); + }); + + it('shows no count with a single account', () => { + useAccountStore.setState({ accounts: [account('work')] } as never); + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + expect(within(screen.getByTestId('account-option')).queryByText('11')).toBeNull(); + }); + + it('gives two accounts with the same name different initials', () => { + useAccountStore.setState({ + accounts: [account('work', { email: 'dso-ux-mail@example.org' }), account('home', { email: 'dso-ux-mail-b@example.org' })], + } as never); + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + const [work, home] = screen.getAllByTestId('account-option'); + expect(within(work).getByText('DM')).toBeInTheDocument(); + expect(within(home).getByText('DB')).toBeInTheDocument(); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index cbb81ba9f..e4aebf87e 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -12,7 +12,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { HelpMenuItems } from "@/components/layout/help-links"; -import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText } from "@/lib/account-utils"; +import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName } from "@/lib/account-utils"; import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; @@ -40,10 +40,10 @@ interface AccountSwitcherProps { allInboxesSelected?: boolean; } -function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" | "xl" }) { +function AccountAvatar({ account, accounts, size = "sm" }: { account: AccountEntry; accounts: AccountEntry[]; size?: "sm" | "md" | "xl" }) { return ( (null); @@ -297,12 +298,16 @@ export function AccountSwitcher({ const unreadByAccount: Record = activeAccountId ? { ...accountMailboxes, [activeAccountId]: ownMailboxes } : accountMailboxes; + // With several accounts every list carries the unread of each, so the new + // mail in another account shows without switching to it. + const severalAccounts = accounts.length > 1; const unreadFor = (account: AccountEntry) => - mvPopover ? inboxUnread(unreadByAccount[account.id]) : undefined; - const allInboxesUnread = totalInboxUnread( - accounts.filter((account) => account.isConnected).map((account) => account.id), - unreadByAccount - ); + mvPopover || severalAccounts ? inboxUnread(unreadByAccount[account.id]) : undefined; + const connectedIds = accounts.filter((account) => account.isConnected).map((account) => account.id); + const allInboxesUnread = totalInboxUnread(connectedIds, unreadByAccount); + // What waits in the accounts not on screen: a dot on the avatar says so. + const othersUnread = totalInboxUnread(connectedIds.filter((id) => id !== activeAccountId), unreadByAccount); + const othersUnreadText = othersUnread > 0 ? tUnread("others", { count: othersUnread }) : null; return ( <> @@ -320,15 +325,24 @@ export function AccountSwitcher({ : "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0", className )} - title={variant !== "expanded" ? displayName : undefined} - aria-label={t("switch_account")} + title={variant !== "expanded" ? [displayName, othersUnreadText].filter(Boolean).join("\n") : undefined} + aria-label={othersUnreadText ? `${t("switch_account")}, ${othersUnreadText}` : t("switch_account")} aria-expanded={open} aria-haspopup="menu" aria-controls={open ? menuId : undefined} > {activeAccount ? ( <> - + + + {othersUnreadText && ( + {variant === "expanded" && ( <>
@@ -382,7 +396,7 @@ export function AccountSwitcher({ {mvPopover && activeAccount && (
- +

{displayName}

{displaySubtitle &&

{displaySubtitle}

} @@ -455,7 +469,7 @@ export function AccountSwitcher({ disabled={isActive} >
- + {isActive && (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index bd7dbcefb..dda7d0ad4 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -19,7 +19,7 @@ import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; -import { getMaxAccounts } from "@/lib/account-utils"; +import { accountAvatarName, getMaxAccounts } from "@/lib/account-utils"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { quotaLevel, type StorageQuota } from "@/lib/quota"; @@ -925,7 +925,7 @@ export function NavigationRail({ title={`${toUnicodeEmail(account.displayName || account.label)} (${toUnicodeEmail(account.email || account.username)})`} > s.accounts.length > 1); const tThirdParty = useTranslations('third_party'); // The relay list depends on it; re-render once the config is known (#903). const noThirdParty = useNoThirdPartyTraffic(); @@ -62,6 +65,7 @@ export function NotificationSettings() { calendarNotificationsEnabled, calendarNotificationSound, faviconUnreadBadge, + unreadBadgeAllAccounts, pushRelayUrl, updateSetting, } = useSettingsStore(); @@ -353,6 +357,17 @@ export function NotificationSettings() { onChange={(checked) => updateSetting('faviconUnreadBadge', checked)} /> + + {/* Only means something with a second account. */} + {severalAccounts && ( + + updateSetting('unreadBadgeAllAccounts', checked)} + disabled={!faviconUnreadBadge} + /> + + )} diff --git a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx index 223420172..7fc74e156 100644 --- a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx +++ b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx @@ -8,6 +8,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { invalidateUnifiedMailboxes, useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; +import { emitPushStateChange } from '@/lib/push-events'; /** * The Pro shell needs every login's folders for its per-account tree. While @@ -86,11 +87,45 @@ describe('useProMultiAccountMailboxes', () => { expect(client('b').getAllMailboxes).toHaveBeenCalledTimes(2); }); - it('stays idle outside the Pro shell', () => { + it('stays idle outside the Pro shell with one account', () => { useSettingsStore.setState({ proInterface: false } as never); - connected(['a', 'b']); + connected(['a']); renderHook(() => useProMultiAccountMailboxes()); expect(client('a').getAllMailboxes).not.toHaveBeenCalled(); expect(client('a').getMailboxes).not.toHaveBeenCalled(); }); + + // Usability test 2026-11-04, mail-6: the account switcher and the badge + // count every account's unread in every layout, not only Mountain View. + it("loads each login's own folders outside the Pro shell once a second account is connected", async () => { + useSettingsStore.setState({ proInterface: false, interfaceLayout: 'default' } as never); + connected(['a', 'b']); + renderHook(() => useProMultiAccountMailboxes()); + await waitFor(() => expect(useEmailStore.getState().accountMailboxes.b).toBeDefined()); + expect(client('b').getMailboxes).toHaveBeenCalledTimes(1); + expect(client('b').getAllMailboxes).not.toHaveBeenCalled(); + }); + + it("reads a background login's folders again after its push, once per burst", async () => { + useSettingsStore.setState({ proInterface: false, interfaceLayout: 'default' } as never); + connected(['a', 'b'], 'a'); + renderHook(() => useProMultiAccountMailboxes()); + await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1)); + + vi.useFakeTimers(); + try { + const change = { '@type': 'StateChange', changed: { b: { Mailbox: 's2', Email: 'e2' } } } as never; + // What the mail view's own push handler does first (createMailPushListener). + invalidateUnifiedMailboxes(client('b') as never); + emitPushStateChange(change, client('b') as never, 'background'); + emitPushStateChange(change, client('b') as never, 'background'); + // The active login's push is the mail view's business, not this hook's. + emitPushStateChange(change, client('a') as never, 'active'); + await vi.advanceTimersByTimeAsync(2000); + } finally { + vi.useRealTimers(); + } + await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(2)); + expect(client('a').getMailboxes).toHaveBeenCalledTimes(1); + }); }); diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts index 60c8cf455..5487a5633 100644 --- a/hooks/use-pro-multi-account-mailboxes.ts +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -7,6 +7,7 @@ import { invalidateUnifiedMailboxes, loadAccountMailboxes } from "@/stores/email import { useSettingsStore } from "@/stores/settings-store"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events"; /** * Keeps `useEmailStore.accountMailboxes` populated with one entry per @@ -14,11 +15,14 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; * sidebar reads this cache to render a Thunderbird-style per-account folder * tree (see [[project_pro_mode]]). Outside Pro the cache stays empty. * - * Mountain View needs the same cache for a different reason: its account - * popover puts the unread waiting in each account next to that address, and - * the active login's folder list knows nothing about the others. It is only - * worth the extra round trips once a second account is connected - with one - * account the popover has nothing to compare. + * Every layout needs the same cache once a second account is connected: the + * account switcher puts the unread waiting in each account next to that + * address, and the app badge and the desktop tray count every account's + * inbox, while the active login's folder list knows nothing about the others. + * With one account there is nothing to compare, and nothing is fetched. + * + * A background login's push says when its counts moved; its list is then read + * again (one Mailbox/get), a moment later so a burst of changes costs one. * * Each login is loaded once per client, not once per connection: while a * browser restores its logins they connect one after another, and reloading @@ -31,7 +35,6 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; export function useProMultiAccountMailboxes(): void { const isEmbedded = useIsEmbedded(); const proInterface = useSettingsStore((s) => s.proInterface); - const mountainView = useSettingsStore((s) => s.interfaceLayout === "mountain-view"); // The ids of the connected logins, as one string: `accounts` itself changes // for display-name and login-time updates that call for no fetch at all. const connectedSignature = useAccountStore((s) => @@ -43,10 +46,10 @@ export function useProMultiAccountMailboxes(): void { useEffect(() => { const connected = connectedSignature ? connectedSignature.split("\n") : []; - if (!proInterface && !isEmbedded && !(mountainView && connected.length > 1)) return; + if (!proInterface && !isEmbedded && connected.length < 2) return; if (connected.length === 0) return; // The Pro sidebar lists each login's shared mailboxes under it (#1486); - // Mountain View's account popover only counts the login's own unread. + // the account switcher and the badge only count the login's own unread. const withShared = proInterface || isEmbedded; if (lastActive.current !== null && lastActive.current !== activeAccountId) { @@ -68,5 +71,32 @@ export function useProMultiAccountMailboxes(): void { if (!ok && loaded.current.get(id) === client) loaded.current.delete(id); }); } - }, [proInterface, isEmbedded, mountainView, connectedSignature, activeAccountId]); + }, [proInterface, isEmbedded, connectedSignature, activeAccountId]); + + // The active login's counts live in `mailboxes` and follow its push there. + // A background login's change re-reads its list. The mail view's own push + // handler (createMailPushListener) drops that login's cached list on the + // push itself, so this is a fetch, or nothing when the all-accounts views + // have already read and published the list again. + useEffect(() => { + const timers = new Map>(); + const stop = subscribePushStateChanges((change, client, role) => { + if (role === "active" || !changesAnyType(change, ["Mailbox", "Email"])) return; + const id = [...loaded.current].find(([, c]) => c === client)?.[0]; + if (!id || timers.has(id)) return; + timers.set(id, setTimeout(() => { + timers.delete(id); + if (loaded.current.get(id) !== client) return; + const withShared = useSettingsStore.getState().proInterface || isEmbedded; + void loadAccountMailboxes(client, id, { withShared }); + }, BACKGROUND_REFRESH_DELAY_MS)); + }); + return () => { + stop(); + for (const timer of timers.values()) clearTimeout(timer); + }; + }, [isEmbedded]); } + +/** How long a background login's changes are gathered before its folders are read again. */ +const BACKGROUND_REFRESH_DELAY_MS = 1500; diff --git a/lib/account-utils.ts b/lib/account-utils.ts index 301333af0..2081326cf 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -174,6 +174,22 @@ export function accountRowText( return { title: name, subtitle: address }; } +/** + * The name an account's avatar takes its initials from. Usually the account's + * name. When another account has the same name, both would show the same two + * letters, so the initials come from the address instead: its local part split + * at dots, dashes, underscores and plus signs ("dso-ux-mail-b" gives "DB", + * "dso-ux-mail" gives "DM"). + */ +export function accountAvatarName(account: NamedAccount, accounts: NamedAccount[]): string { + const name = account.displayName || account.label; + const { title, subtitle } = accountRowText(account, accounts); + if (subtitle !== null || title !== (account.email || account.username)) return name; + const local = title.split('@')[0] ?? title; + const words = local.split(/[.\-_+]+/).filter(Boolean); + return words.length > 0 ? words.join(' ') : name || title; +} + /** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */ export interface OrderableAccount { id: string; diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts index 67323171c..99544462c 100644 --- a/lib/plugin-sandbox/host-api-app.ts +++ b/lib/plugin-sandbox/host-api-app.ts @@ -147,7 +147,7 @@ export const SHARED_SETTINGS = [ 'filenameSpaceReplacement', 'filenameLowercase', 'filenameStripDiacritics', 'filenameCollapseSeparators', 'postExportAction', 'recipientMentionsEnabled', 'mentionAddsRecipientTo', 'rtlEditingSupport', 'quickReplyEnabled', 'replyIdentityMatch', 'attachmentReminderEnabled', 'attachmentReminderKeywords', 'emptySubjectWarningEnabled', 'placeholderWarningEnabled', - 'notificationSoundChoice', 'pushNotifyInboxOnly', 'faviconUnreadBadge', 'calendarNotificationSound', 'doNotDisturb', 'visualAlerts', + 'notificationSoundChoice', 'pushNotifyInboxOnly', 'faviconUnreadBadge', 'unreadBadgeAllAccounts', 'calendarNotificationSound', 'doNotDisturb', 'visualAlerts', 'calendarInvitationParsingEnabled', 'showTimeInMonthView', 'calendarTintedEvents', 'calendarMonthTimedEvents', 'calendarLimitHours', 'calendarDayStartHour', 'calendarDayEndHour', 'calendarHideNonWorkingDays', 'calendarFreeScroll', 'calendarHoverPreview', 'showTasksOnCalendar', diff --git a/lib/settings-schema.ts b/lib/settings-schema.ts index 995bb4e41..b5ac5b036 100644 --- a/lib/settings-schema.ts +++ b/lib/settings-schema.ts @@ -306,6 +306,7 @@ const ENTRIES: SettingSchemaEntry[] = [ choice('notificationSoundChoice', 'notifications', `${NO}sound_selection.choose`, opts(`${NO}sounds.`, NOTIFICATION_SOUND_IDS)), bool('pushNotifyInboxOnly', 'notifications', `${NO}email.inbox_only`), bool('faviconUnreadBadge', 'notifications', `${AP}favicon_unread_badge.label`), + bool('unreadBadgeAllAccounts', 'notifications', 'account_unread.badge_all_accounts.label'), bool('calendarNotificationsEnabled', 'notifications', `${NO}calendar.enabled`), bool('calendarNotificationSound', 'notifications', `${NO}calendar.sound`), bool('doNotDisturb', 'accessibility', `${A}do_not_disturb.label`), diff --git a/lib/settings-search.ts b/lib/settings-search.ts index 982b15062..383a25826 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -86,6 +86,7 @@ export const tabSearchEntries: Record = { { label: 'settings.notifications.email.enabled' }, { label: 'settings.notifications.email.sound' }, { label: 'settings.appearance.favicon_unread_badge.label' }, + { label: 'account_unread.badge_all_accounts.label' }, { label: 'settings.notifications.calendar.title' }, { label: 'settings.notifications.calendar.enabled' }, { label: 'settings.notifications.sound_selection.title' }, diff --git a/locales/ar/common.json b/locales/ar/common.json index ed9cbecb0..0537b16ed 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "تم استخدام {percent}% من {total}", "accessibility": "إمكانية الوصول" }, + "account_unread": { + "others": "غير مقروءة في الحسابات الأخرى: {count}", + "badge_all_accounts": { + "label": "احتساب البريد غير المقروء في كل الحسابات", + "description": "الرقم على علامة التبويب وأيقونة التطبيق وشريط النظام يجمع صناديق الوارد لكل الحسابات المسجّل دخولها." + } + }, "protocol_handlers": { "title": "التطبيقات الافتراضية", "description": "اختر ما إذا كانت روابط البريد والتقويم تُفتح في {appName}. من الناحية التقنية، يسجّل {appName} نفسه كمعالج بروتوكول لروابط mailto: وwebcal:.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5e3d4916d..0b3ee80b6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% de {total} utilitzat", "accessibility": "Accessibilitat" }, + "account_unread": { + "others": "Sense llegir en altres comptes: {count}", + "badge_all_accounts": { + "label": "Compta el correu no llegit de tots els comptes", + "description": "El número de la pestanya, la icona de l'aplicació i la safata suma les safates d'entrada de tots els comptes amb la sessió iniciada." + } + }, "protocol_handlers": { "title": "Aplicacions predeterminades", "description": "Trieu si els enllaços de correu i calendari s'obren al {appName}. Tècnicament, el {appName} es registra com a gestor de protocol per als enllaços mailto: i webcal:.", diff --git a/locales/cs/common.json b/locales/cs/common.json index ace3e4105..5929cbfd9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Využito {percent} % z {total}", "accessibility": "Přístupnost" }, + "account_unread": { + "others": "Nepřečtené v ostatních účtech: {count}", + "badge_all_accounts": { + "label": "Počítat nepřečtenou poštu ve všech účtech", + "description": "Číslo na kartě, na ikoně aplikace a v oznamovací oblasti sčítá doručené složky všech přihlášených účtů." + } + }, "protocol_handlers": { "title": "Výchozí aplikace", "description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v aplikaci {appName}. Technicky se {appName} registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.", diff --git a/locales/da/common.json b/locales/da/common.json index b473c8379..6f3733da6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% af {total} brugt", "accessibility": "Tilgængelighed" }, + "account_unread": { + "others": "Ulæste i andre konti: {count}", + "badge_all_accounts": { + "label": "Tæl ulæst post i alle konti", + "description": "Tallet på fanen, appikonet og i proceslinjen lægger indbakkerne for alle loggede-ind konti sammen." + } + }, "protocol_handlers": { "title": "Standardapps", "description": "Vælg om e-mail- og kalenderlinks åbnes i {appName}. Teknisk set registrerer {appName} sig som protokolhåndtering for mailto:- og webcal:-links.", diff --git a/locales/de/common.json b/locales/de/common.json index 9cb2097f7..67ff209b4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent} % von {total} belegt", "accessibility": "Barrierefreiheit" }, + "account_unread": { + "others": "Ungelesen in anderen Konten: {count}", + "badge_all_accounts": { + "label": "Ungelesene Mails aller Konten zählen", + "description": "Die Zahl am Tab, am App-Symbol und im Infobereich zählt die Posteingänge aller angemeldeten Konten zusammen." + } + }, "protocol_handlers": { "title": "Standard-Apps", "description": "Legen Sie fest, ob E-Mail- und Kalender-Links in {appName} geöffnet werden. Technisch registriert sich {appName} dafür als Protokoll-Handler für mailto: und webcal:.", diff --git a/locales/en/common.json b/locales/en/common.json index 84f9389a7..d32a34bc9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% of {total} used", "accessibility": "Accessibility" }, + "account_unread": { + "others": "Unread in other accounts: {count}", + "badge_all_accounts": { + "label": "Count unread mail in all accounts", + "description": "The number on the tab, the app icon and the tray adds up the inboxes of every signed-in account." + } + }, "protocol_handlers": { "title": "Default apps", "description": "Choose whether email and calendar links open in {appName}. Technically, {appName} registers as a protocol handler for mailto: and webcal: links.", diff --git a/locales/es/common.json b/locales/es/common.json index a44fce868..f7f50067d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% de {total} usado", "accessibility": "Accesibilidad" }, + "account_unread": { + "others": "Sin leer en otras cuentas: {count}", + "badge_all_accounts": { + "label": "Contar el correo sin leer de todas las cuentas", + "description": "El número de la pestaña, el icono de la aplicación y la bandeja del sistema suma las bandejas de entrada de todas las cuentas con sesión iniciada." + } + }, "protocol_handlers": { "title": "Aplicaciones predeterminadas", "description": "Elige si los enlaces de correo y calendario se abren en {appName}. Técnicamente, {appName} se registra como controlador de protocolo para enlaces mailto: y webcal:.", diff --git a/locales/et/common.json b/locales/et/common.json index ace51f3b5..a40644ac4 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% mahust {total} kasutatud", "accessibility": "Juurdepääsetavus" }, + "account_unread": { + "others": "Lugemata teistes kontodes: {count}", + "badge_all_accounts": { + "label": "Loe kõigi kontode lugemata kirjad kokku", + "description": "Vahekaardi, rakenduse ikooni ja teavitusala arv liidab kokku kõigi sisselogitud kontode postkastid." + } + }, "protocol_handlers": { "title": "Vaikerakendused", "description": "Vali, kas e-posti ja kalendri lingid avanevad rakenduses {appName}. Tehniliselt registreerib {appName} end mailto: ja webcal: linkide protokollikäsitlejana.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9961aab01..157117c51 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}٪ از {total} استفاده شده", "accessibility": "دسترس‌پذیری" }, + "account_unread": { + "others": "خوانده‌نشده در حساب‌های دیگر: {count}", + "badge_all_accounts": { + "label": "شمارش نامه‌های خوانده‌نشده همه حساب‌ها", + "description": "عدد روی زبانه، نماد برنامه و سینی سیستم، صندوق‌های ورودی همه حساب‌های واردشده را با هم جمع می‌کند." + } + }, "protocol_handlers": { "title": "برنامه‌های پیش‌فرض", "description": "انتخاب کنید لینک‌های ایمیل و تقویم در {appName} باز شوند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 750e84417..c3829f87e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent} % de {total} utilisés", "accessibility": "Accessibilité" }, + "account_unread": { + "others": "Non lus dans les autres comptes : {count}", + "badge_all_accounts": { + "label": "Compter les messages non lus de tous les comptes", + "description": "Le nombre sur l'onglet, l'icône de l'application et la zone de notification additionne les boîtes de réception de tous les comptes connectés." + } + }, "protocol_handlers": { "title": "Applications par défaut", "description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans {appName}. Techniquement, {appName} s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.", diff --git a/locales/he/common.json b/locales/he/common.json index 23cb11fa8..7ad373151 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -214,6 +214,13 @@ "mv_storage_used": "נוצלו {percent}% מתוך {total}", "accessibility": "נגישות" }, + "account_unread": { + "others": "לא נקראו בחשבונות האחרים: {count}", + "badge_all_accounts": { + "label": "ספירת דואר שלא נקרא בכל החשבונות", + "description": "המספר בכרטיסייה, בסמל האפליקציה ובמגש המערכת מחבר את תיבות הדואר הנכנס של כל החשבונות המחוברים." + } + }, "sidebar_apps": { "modal_title": "אפליקציות בסרגל הצד", "add_new": "הוסף אפליקציה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 172fa6df6..67fc90fa7 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{total} {percent}%-a foglalt", "accessibility": "Kisegítő lehetőségek" }, + "account_unread": { + "others": "Olvasatlan a többi fiókban: {count}", + "badge_all_accounts": { + "label": "Az összes fiók olvasatlan leveleinek számlálása", + "description": "A lapon, az alkalmazásikonon és a tálcán látható szám az összes bejelentkezett fiók beérkezett leveleit adja össze." + } + }, "protocol_handlers": { "title": "Alapértelmezett alkalmazások", "description": "Válaszd ki, hogy az e-mail és naptár hivatkozások a(z) {appName} alkalmazásban nyíljanak meg. Technikailag a(z) {appName} protokollkezelőként regisztrálja magát a mailto: és webcal: hivatkozásokhoz.", diff --git a/locales/it/common.json b/locales/it/common.json index 71f13cca0..29326a99f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% di {total} utilizzato", "accessibility": "Accessibilità" }, + "account_unread": { + "others": "Non letti negli altri account: {count}", + "badge_all_accounts": { + "label": "Conta la posta non letta di tutti gli account", + "description": "Il numero sulla scheda, sull'icona dell'app e nell'area di notifica somma le caselle di posta in arrivo di tutti gli account collegati." + } + }, "protocol_handlers": { "title": "App predefinite", "description": "Scegli se i link e-mail e calendario devono aprirsi in {appName}. Tecnicamente, {appName} si registra come gestore di protocollo per i link mailto: e webcal:.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d382e843..98020f31e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{total} のうち {percent}% を使用中", "accessibility": "アクセシビリティ" }, + "account_unread": { + "others": "他のアカウントの未読: {count}", + "badge_all_accounts": { + "label": "すべてのアカウントの未読を数える", + "description": "タブ、アプリのアイコン、通知領域の数字に、サインイン中のすべてのアカウントの受信トレイを合計して表示します。" + } + }, "protocol_handlers": { "title": "既定のアプリ", "description": "メールとカレンダーのリンクを {appName} で開くかどうかを選択します。技術的には、{appName} は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f9643ad0..897e453ef 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{total} 중 {percent}% 사용", "accessibility": "접근성" }, + "account_unread": { + "others": "다른 계정의 읽지 않은 메일: {count}", + "badge_all_accounts": { + "label": "모든 계정의 읽지 않은 메일 세기", + "description": "탭, 앱 아이콘, 알림 영역의 숫자에 로그인한 모든 계정의 받은편지함을 합산해 표시해요." + } + }, "protocol_handlers": { "title": "기본 앱", "description": "이메일 및 캘린더 링크를 {appName}에서 열지 선택하세요. 기술적으로 {appName}은(는) mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bc8ef83..2599cc500 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Izmantoti {percent}% no {total}", "accessibility": "Pieejamība" }, + "account_unread": { + "others": "Nelasītas citos kontos: {count}", + "badge_all_accounts": { + "label": "Skaitīt nelasītās vēstules visos kontos", + "description": "Skaitlis cilnē, lietotnes ikonā un paziņojumu apgabalā saskaita visu pierakstīto kontu iesūtnes." + } + }, "protocol_handlers": { "title": "Noklusējuma lietotnes", "description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt {appName}. Tehniski {appName} reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 152574c4e..68967ba2e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{total}-с {percent}% ашигласан", "accessibility": "Хүртээмж" }, + "account_unread": { + "others": "Бусад бүртгэлд уншаагүй: {count}", + "badge_all_accounts": { + "label": "Бүх бүртгэлийн уншаагүй захидлыг тоолох", + "description": "Таб, аппын дүрс болон мэдэгдлийн хэсэг дээрх тоо нь нэвтэрсэн бүх бүртгэлийн ирсэн захидлыг нэгтгэнэ." + } + }, "protocol_handlers": { "title": "Өгөгдмөл програмууд", "description": "И-мэйл болон хуанлийн холбоосууд {appName} дээр нээгдэх эсэхийг сонгоно уу. Техникийн хувьд {appName} нь mailto: болон webcal: холбоосуудад протокол зохицуулагчаар бүртгүүлдэг.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a6c14f76..bcda16c1d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent} % av {total} brukt", "accessibility": "Tilgjengelighet" }, + "account_unread": { + "others": "Uleste i andre kontoer: {count}", + "badge_all_accounts": { + "label": "Tell ulest e-post i alle kontoer", + "description": "Tallet på fanen, appikonet og i systemstatusfeltet legger sammen innboksene til alle påloggede kontoer." + } + }, "protocol_handlers": { "title": "Standardapper", "description": "Velg om e-post- og kalenderlenker skal åpnes i {appName}. Teknisk sett registrerer {appName} seg som protokollbehandler for mailto:- og webcal:-lenker.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c57a4007..9eb958686 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% van {total} gebruikt", "accessibility": "Toegankelijkheid" }, + "account_unread": { + "others": "Ongelezen in andere accounts: {count}", + "badge_all_accounts": { + "label": "Ongelezen mail van alle accounts tellen", + "description": "Het getal op het tabblad, het app-pictogram en in het systeemvak telt de inboxen van alle aangemelde accounts bij elkaar op." + } + }, "protocol_handlers": { "title": "Standaardapps", "description": "Kies of e-mail- en kalenderlinks in {appName} worden geopend. Technisch registreert {appName} zich als protocolhandler voor mailto:- en webcal:-links.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 20bd079ae..6712c74bc 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Wykorzystano {percent}% z {total}", "accessibility": "Dostępność" }, + "account_unread": { + "others": "Nieprzeczytane na innych kontach: {count}", + "badge_all_accounts": { + "label": "Licz nieprzeczytaną pocztę ze wszystkich kont", + "description": "Liczba na karcie, ikonie aplikacji i w zasobniku sumuje skrzynki odbiorcze wszystkich zalogowanych kont." + } + }, "protocol_handlers": { "title": "Aplikacje domyślne", "description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w {appName}. Technicznie {appName} rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2452edf4a..dfc82a03e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% de {total} utilizado", "accessibility": "Acessibilidade" }, + "account_unread": { + "others": "Não lidas em outras contas: {count}", + "badge_all_accounts": { + "label": "Contar os e-mails não lidos de todas as contas", + "description": "O número na aba, no ícone do app e na bandeja do sistema soma as caixas de entrada de todas as contas conectadas." + } + }, "protocol_handlers": { "title": "Aplicativos padrão", "description": "Escolha se links de e-mail e calendário devem abrir no {appName}. Tecnicamente, o {appName} se registra como manipulador de protocolo para links mailto: e webcal:.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 03bfef66a..5f007a176 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{percent}% din {total} folosit", "accessibility": "Accesibilitate" }, + "account_unread": { + "others": "Necitite în alte conturi: {count}", + "badge_all_accounts": { + "label": "Numără mesajele necitite din toate conturile", + "description": "Numărul de pe filă, de pe pictograma aplicației și din zona de notificare adună căsuțele de primire ale tuturor conturilor conectate." + } + }, "protocol_handlers": { "title": "Aplicații implicite", "description": "Alegeți dacă linkurile către e-mail și calendar se deschid în {appName}. Din punct de vedere tehnic, {appName} se înregistrează ca gestionar de protocol pentru linkurile de tip mailto: și webcal:.", diff --git a/locales/ru/common.json b/locales/ru/common.json index ebb25a96f..be20722d5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Использовано {percent}% из {total}", "accessibility": "Специальные возможности" }, + "account_unread": { + "others": "Непрочитанные в других аккаунтах: {count}", + "badge_all_accounts": { + "label": "Считать непрочитанные письма во всех аккаунтах", + "description": "Число на вкладке, на значке приложения и в трее складывает входящие всех аккаунтов, в которые выполнен вход." + } + }, "protocol_handlers": { "title": "Приложения по умолчанию", "description": "Выберите, должны ли ссылки электронной почты и календаря открываться в {appName}. Технически {appName} регистрируется как обработчик протокола для ссылок mailto: и webcal:.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 43343c99e..8e05da256 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Využité {percent} % z {total}", "accessibility": "Prístupnosť" }, + "account_unread": { + "others": "Neprečítané v ostatných účtoch: {count}", + "badge_all_accounts": { + "label": "Počítať neprečítanú poštu vo všetkých účtoch", + "description": "Číslo na karte, na ikone aplikácie a v oblasti oznámení sčítava doručenú poštu všetkých prihlásených účtov." + } + }, "protocol_handlers": { "title": "Predvolené aplikácie", "description": "Vyberte, či sa e-mailové a kalendárové odkazy otvárajú v aplikácii {appName}. {appName} sa registruje ako obslužná aplikácia protokolu pre odkazy mailto: a webcal:.", diff --git a/locales/th/common.json b/locales/th/common.json index d4aef2af2..a65253edd 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -345,6 +345,13 @@ "mv_storage_used": "ใช้ไป {percent}% จาก {total}", "accessibility": "การเข้าถึง" }, + "account_unread": { + "others": "ยังไม่ได้อ่านในบัญชีอื่น: {count}", + "badge_all_accounts": { + "label": "นับอีเมลที่ยังไม่ได้อ่านของทุกบัญชี", + "description": "ตัวเลขบนแท็บ ไอคอนแอป และถาดระบบ จะรวมกล่องจดหมายเข้าของทุกบัญชีที่ลงชื่อเข้าใช้" + } + }, "language": { "title": "ภาษา", "english": "English", diff --git a/locales/tr/common.json b/locales/tr/common.json index 03393f7f0..478699e25 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "{total} alanın %{percent} kadarı kullanıldı", "accessibility": "Erişilebilirlik" }, + "account_unread": { + "others": "Diğer hesaplarda okunmamış: {count}", + "badge_all_accounts": { + "label": "Tüm hesaplardaki okunmamış postaları say", + "description": "Sekmedeki, uygulama simgesindeki ve sistem tepsisindeki sayı, oturum açılmış tüm hesapların gelen kutularını toplar." + } + }, "protocol_handlers": { "title": "Varsayılan uygulamalar", "description": "E-posta ve takvim bağlantılarının {appName} uygulamasında açılıp açılmayacağını seçin. Teknik olarak {appName}, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.", diff --git a/locales/uk/common.json b/locales/uk/common.json index f033cd1ee..a968e8ad0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Використано {percent}% з {total}", "accessibility": "Доступність" }, + "account_unread": { + "others": "Непрочитані в інших облікових записах: {count}", + "badge_all_accounts": { + "label": "Рахувати непрочитані листи в усіх облікових записах", + "description": "Число на вкладці, на значку застосунку та в треї підсумовує вхідні всіх облікових записів, у які виконано вхід." + } + }, "protocol_handlers": { "title": "Програми за замовчуванням", "description": "Виберіть, чи відкривати посилання електронної пошти та календаря в {appName}. Технічно {appName} реєструється як обробник протоколу для посилань mailto: і webcal:.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9faa09bde..664a14d64 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "Đã dùng {percent}% dung lượng {total}", "accessibility": "Hỗ trợ tiếp cận" }, + "account_unread": { + "others": "Chưa đọc trong các tài khoản khác: {count}", + "badge_all_accounts": { + "label": "Đếm thư chưa đọc của mọi tài khoản", + "description": "Con số trên tab, biểu tượng ứng dụng và khay hệ thống cộng hộp thư đến của mọi tài khoản đã đăng nhập." + } + }, "protocol_handlers": { "title": "Ứng dụng mặc định", "description": "Chọn xem các liên kết email và lịch có mở trong {appName} hay không. Về mặt kỹ thuật, {appName} đăng ký làm trình xử lý giao thức cho các liên kết mailto: và webcal:.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eed67bf21..c613c620a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "已使用 {total} 中的 {percent}%", "accessibility": "無障礙" }, + "account_unread": { + "others": "其他帳號中的未讀郵件:{count}", + "badge_all_accounts": { + "label": "計算所有帳號的未讀郵件", + "description": "分頁、應用程式圖示和系統匣上的數字會合計所有已登入帳號的收件匣。" + } + }, "protocol_handlers": { "title": "預設應用程式", "description": "選擇電子郵件與行事曆連結是否要在 {appName} 開啟。{appName} 會註冊為 mailto: 與 webcal: 連結的通訊協定處理常式。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0edeb8c81..938b5c782 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -215,6 +215,13 @@ "mv_storage_used": "已使用 {total} 中的 {percent}%", "accessibility": "无障碍" }, + "account_unread": { + "others": "其他账户中的未读邮件:{count}", + "badge_all_accounts": { + "label": "统计所有账户的未读邮件", + "description": "标签页、应用图标和托盘上的数字会合计所有已登录账户的收件箱。" + } + }, "protocol_handlers": { "title": "默认应用", "description": "选择是否在 {appName} 中打开电子邮件和日历链接。从技术上讲,{appName} 会注册为 mailto: 和 webcal: 链接的协议处理程序。", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 1d771cb5f..9cb1d4e07 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -763,6 +763,7 @@ interface SettingsState extends AccessibilitySettings { senderFavicons: boolean; showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count + unreadBadgeAllAccounts: boolean; // That badge (and the desktop tray) counts every signed-in account's inbox, not only the open one // Sidebar colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.) @@ -1068,6 +1069,7 @@ const DEFAULT_SETTINGS = { senderFavicons: true, showAvatarsInJunk: false, faviconUnreadBadge: true, + unreadBadgeAllAccounts: true, // Sidebar colorfulSidebarIcons: true, @@ -1483,6 +1485,7 @@ export const useSettingsStore = create()( senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, faviconUnreadBadge: state.faviconUnreadBadge, + unreadBadgeAllAccounts: state.unreadBadgeAllAccounts, colorfulSidebarIcons: state.colorfulSidebarIcons, tintListRowsByTag: state.tintListRowsByTag, tintListRowsByAccount: state.tintListRowsByAccount, From 60a97303c2975718ffa7844a662b7ae0eaf62166 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:17:05 +0100 Subject: [PATCH 2/5] fix: open the folder when its unread count is clicked, and search all folders again on a new search Two sidebar tests asserted the old unread-count button (it filtered the folder to unread without opening it, in a 24px target); they now check that the count is plain text and opens the folder. --- .../sidebar-folder-touch-menu.test.tsx | 15 +++--- .../__tests__/sidebar-row-click-area.test.tsx | 17 ++++--- components/layout/sidebar.tsx | 46 +++---------------- components/mail/mail-app.tsx | 44 ------------------ .../email-store-search-scope.test.ts | 22 ++++++++- stores/email-store.ts | 6 +++ 6 files changed, 50 insertions(+), 100 deletions(-) diff --git a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx index 349511372..6580f0352 100644 --- a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx +++ b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx @@ -68,19 +68,18 @@ describe('folder list from the keyboard', () => { it('has no button inside another button', () => { useSettingsStore.setState({ hideAccountSwitcher: true } as never); const withUnread = mailboxes.map((m) => ({ ...m, unreadEmails: 3, totalEmails: 5 }) as Mailbox); - const { container } = render( {}} onCreateFolder={() => {}} />); + const { container } = render( {}} />); expect(container.querySelectorAll('button button, button [role="button"]')).toHaveLength(0); }); - it('keeps a one-digit unread count next to the total in its 24px target', () => { + // The count is text now (mail-8): the row's button is the keyboard target. + it('keeps the unread count out of the tab order', () => { useSettingsStore.setState({ hideAccountSwitcher: true } as never); const withUnread = mailboxes.map((m) => ({ ...m, unreadEmails: 4, totalEmails: 7 }) as Mailbox); - render( {}} />); - const unread = row('travel').querySelector('[data-testid="folder-counts"] button')!; - expect(unread.textContent).toBe('4'); - expect(unread.className).toContain('min-w-6'); - expect(unread.className).toContain('justify-end'); - expect(unread.className).not.toContain('justify-center'); + render(); + const counts = row('travel').querySelector('[data-testid="folder-counts"]')!; + expect(counts.textContent).toContain('4'); + expect(counts.querySelector('button, [tabindex]')).toBeNull(); }); }); diff --git a/components/layout/__tests__/sidebar-row-click-area.test.tsx b/components/layout/__tests__/sidebar-row-click-area.test.tsx index b09e4363b..e6e215ce9 100644 --- a/components/layout/__tests__/sidebar-row-click-area.test.tsx +++ b/components/layout/__tests__/sidebar-row-click-area.test.tsx @@ -72,14 +72,17 @@ describe.each([ }); }); -describe('folder row click area with the unread filter', () => { - it('filters to unread from the unread count without also opening the folder', () => { +// Usability test 2026-11-04, mail-8: the count was a button of its own that +// turned on an Unread filter and an "Inbox" search scope, both of which stayed +// and broke later searches. It is part of the row now and opens the folder. +describe('folder row click area on the unread count', () => { + it('opens the folder from the unread count', () => { useSettingsStore.setState({ interfaceLayout: 'default', hideAccountSwitcher: true, enableUnifiedMailbox: false } as never); const onMailboxSelect = vi.fn(); - const onUnreadFilterClick = vi.fn(); - render(); - fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!); - expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts'); - expect(onMailboxSelect).not.toHaveBeenCalled(); + render(); + const counts = row('receipts').querySelector('[data-testid="folder-counts"]')!; + expect(counts.querySelector('button')).toBeNull(); + fireEvent.click(counts.firstElementChild!); + expect(onMailboxSelect).toHaveBeenCalledWith('receipts'); }); }); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index e525f3737..eab2e30a2 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -116,7 +116,6 @@ interface SidebarProps { */ mvShell?: boolean; onSidebarClose?: () => void; - onUnreadFilterClick?: (mailboxId: string) => void; onMarkFolderRead?: (mailboxId: string) => void; onMarkFolderTreeRead?: (mailboxId: string) => void; onMarkAllFoldersRead?: () => void; @@ -235,16 +234,13 @@ function SidebarRowCounts({ total, role, mailboxId, - onUnreadClick, }: { unread?: number; total?: number; role?: string | null; mailboxId?: string; isSelected: boolean; - onUnreadClick?: () => void; }) { - const t = useTranslations('sidebar'); const tShell = useTranslations('shell'); const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount); const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view'); @@ -287,26 +283,12 @@ function SidebarRowCounts({ const unreadClass = "text-xs font-semibold tabular-nums text-foreground"; const totalClass = "text-xs tabular-nums text-muted-foreground"; + // Text, not a button: a click on the count opens the folder like the rest + // of the row. It used to turn on an Unread filter and an "Inbox" search + // scope that stayed and broke later searches (usability test 2026-11-04). const unreadNode = unreadCount > 0 ? ( - onUnreadClick ? ( - - ) : ( - // Said with the folder's name already. - - ) + // Said with the folder's name already. + ) : null; return ( @@ -367,7 +349,6 @@ interface SidebarRowProps { hasChildren?: boolean; isExpanded?: boolean; onExpandToggle?: () => void; - onUnreadClick?: () => void; isCollapsed: boolean; dropHandlers?: Record; isValidDropTarget?: boolean; @@ -395,7 +376,6 @@ function SidebarRow({ hasChildren = false, isExpanded = false, onExpandToggle, - onUnreadClick, isCollapsed, dropHandlers, isValidDropTarget, @@ -542,8 +522,7 @@ function SidebarRow({ )} - {/* Beside the row's button, not in it: the unread count is a button - of its own (it filters the folder to unread). */} + {/* Beside the row's button, not in it; a click on it opens the folder through the row. */} {!isCollapsed && ( )} {hasMenu && !isCollapsed && coarsePointer && ( @@ -693,7 +671,6 @@ function MailboxTreeItem({ onMailboxSelect, onToggleExpand, isCollapsed, - onUnreadFilterClick, colorful, onContextMenu, dragAccountId = null, @@ -707,7 +684,6 @@ function MailboxTreeItem({ onMailboxSelect?: (id: string) => void; onToggleExpand: (id: string) => void; isCollapsed: boolean; - onUnreadFilterClick?: (mailboxId: string) => void; colorful: boolean; onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void; /** Connected-account id this folder is listed under; null = active account. @@ -789,7 +765,6 @@ function MailboxTreeItem({ hasChildren={hasChildren} isExpanded={isExpanded} onExpandToggle={() => onToggleExpand(node.id)} - onUnreadClick={() => onUnreadFilterClick?.(node.id)} isCollapsed={isCollapsed} dropHandlers={ folderDragHandlers || (globalDragging && !pickOnly) @@ -814,7 +789,6 @@ function MailboxTreeItem({ onMailboxSelect={onMailboxSelect} onToggleExpand={onToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={onUnreadFilterClick} colorful={colorful} onContextMenu={onContextMenu} dragAccountId={dragAccountId} @@ -1127,7 +1101,6 @@ function SidebarImpl({ onTagSelect, onCompose, onSidebarClose, - onUnreadFilterClick, onMarkFolderRead, onMarkFolderTreeRead, onMarkAllFoldersRead, @@ -1513,7 +1486,6 @@ function SidebarImpl({ onMailboxSelect={onSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={active ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={active ? handleMailboxContextMenu : undefined} dragAccountId={dragAccountId} @@ -1586,7 +1558,6 @@ function SidebarImpl({ onMailboxSelect={onSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={active ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={active ? (e, node) => handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders) : undefined} pickOnly={!active} @@ -1632,7 +1603,6 @@ function SidebarImpl({ onMailboxSelect={onSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={active ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={active ? handleMailboxContextMenu : undefined} pickOnly={!active} @@ -2090,7 +2060,6 @@ function SidebarImpl({ } onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={isActive ? handleMailboxContextMenu : undefined} dragAccountId={isActive ? null : account.id} @@ -2157,7 +2126,6 @@ function SidebarImpl({ onMailboxSelect={onMailboxSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={onUnreadFilterClick} colorful={colorfulSidebarIcons} onContextMenu={handleMailboxContextMenu} /> @@ -2200,7 +2168,6 @@ function SidebarImpl({ onMailboxSelect={onMailboxSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={onUnreadFilterClick} colorful={colorfulSidebarIcons} onContextMenu={handleMailboxContextMenu} /> @@ -2242,7 +2209,6 @@ function SidebarImpl({ onMailboxSelect={onMailboxSelect} onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} - onUnreadFilterClick={onUnreadFilterClick} colorful={colorfulSidebarIcons} onContextMenu={handleMailboxContextMenu} /> diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..8e082b4d8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -529,7 +529,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isAdvancedSearchOpen, setSearchFilters, setSearchMailboxId, - scopeSearchToOpenFolder, clearSearchFilters, toggleAdvancedSearch, advancedSearch, @@ -618,7 +617,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isAdvancedSearchOpen: state.isAdvancedSearchOpen, setSearchFilters: state.setSearchFilters, setSearchMailboxId: state.setSearchMailboxId, - scopeSearchToOpenFolder: state.scopeSearchToOpenFolder, clearSearchFilters: state.clearSearchFilters, toggleAdvancedSearch: state.toggleAdvancedSearch, advancedSearch: state.advancedSearch, @@ -3892,47 +3890,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; - const handleUnreadFilterClick = async (mailboxId: string) => { - const isTogglingOff = selectedMailbox === mailboxId && !selectedKeyword && searchFilters.isUnread === true; - - // Open the mailbox unless it is already the one listed. A tag view keeps - // selectedMailbox, so it counts as another view. - if (selectedMailbox !== mailboxId || selectedKeyword) { - if (isUnifiedView) exitUnifiedView(); - setScheduledView(false); - selectMailbox(mailboxId); - selectEmail(null); - } - - // On mobile, close sidebar and go to list view - if (isMobile) { - setSidebarOpen(false); - setActiveView("list"); - } - - // On tablet, show the list again - if (isTablet) { - setTabletListVisible(true); - setSidebarOpen(false); - } - - if (isTogglingOff) { - // Disable the unread filter and show all emails - clearSearchFilters(); - if (client) { - await fetchEmails(client, mailboxId); - } - } else { - // Enable unread filter, in this folder only - clearSearchFilters(); - scopeSearchToOpenFolder(); - setSearchFilters({ isUnread: true }); - if (client) { - await advancedSearch(client); - } - } - }; - const tCtxMenu = t; const handleMarkFolderRead = async (mailboxId: string) => { @@ -4755,7 +4712,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onOpenStarred: mvShell ? goToStarred : undefined, onMailboxSelect: handleMailboxSelect, onTagSelect: handleTagSelect, - onUnreadFilterClick: handleUnreadFilterClick, onMarkFolderRead: handleMarkFolderRead, onMarkFolderTreeRead: handleMarkFolderTreeRead, onMarkAllFoldersRead: handleMarkAllFoldersRead, diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 3ef44c02b..ef3d9ba14 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -348,7 +348,9 @@ describe('unread badge scope', () => { }); }); - // What mail-app's handleUnreadFilterClick does with the store. + // What a click on a folder's unread count used to do with the store. The + // count is plain text now (usability test 2026-11-04, mail-8); the store + // keeps the folder-following scope. async function clickUnreadBadge(mailboxId: string) { const store = useEmailStore.getState(); store.selectMailbox(mailboxId); @@ -401,6 +403,24 @@ describe('unread badge scope', () => { expect(useEmailStore.getState().searchMailboxId).toBe('owner-x:x-inbox'); }); + // mail-8: the scope stayed on Inbox and a later search for mail in + // Archive found nothing. + it('drops the following scope when a new search is typed', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().setSearchQuery('O'); + useEmailStore.getState().setSearchQuery('Old conversation'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + expect(useEmailStore.getState().searchScopeFollowsFolder).toBe(false); + }); + + it('keeps a scope picked in the filter panel when a new search is typed', () => { + useEmailStore.getState().setSearchMailboxId('inbox'); + useEmailStore.getState().setSearchQuery('Old conversation'); + + expect(useEmailStore.getState().searchMailboxId).toBe('inbox'); + }); + it('stops following once the filters are cleared', async () => { await clickUnreadBadge('inbox'); useEmailStore.getState().clearSearchFilters(); diff --git a/stores/email-store.ts b/stores/email-store.ts index 96d37325e..dde5fa8c6 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -3625,6 +3625,12 @@ export const useEmailStore = create((set, get) => ({ setSearchQuery: (query) => set((state) => ({ searchQuery: query, ...(query === state.searchQuery ? {} : NO_SELECTION(state)), + // A new search looks where searches look by default. A scope that only + // followed the open folder (scopeSearchToOpenFolder) is dropped; one the + // person picked in the filter panel stays. + ...(state.searchScopeFollowsFolder && !state.searchQuery.trim() && query.trim() + ? { searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox), searchScopeFollowsFolder: false } + : {}), })), setQuota: (quota) => set({ quota }), From c721c737b731f90191ddffe3dce089dd3bcb531e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:38:53 +0100 Subject: [PATCH 3/5] fix: list the all-accounts badge setting in settings search only with several accounts --- lib/settings-search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/settings-search.ts b/lib/settings-search.ts index 383a25826..f130aba5a 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -86,7 +86,7 @@ export const tabSearchEntries: Record = { { label: 'settings.notifications.email.enabled' }, { label: 'settings.notifications.email.sound' }, { label: 'settings.appearance.favicon_unread_badge.label' }, - { label: 'account_unread.badge_all_accounts.label' }, + { label: 'account_unread.badge_all_accounts.label', when: 'multiAccount' }, { label: 'settings.notifications.calendar.title' }, { label: 'settings.notifications.calendar.enabled' }, { label: 'settings.notifications.sound_selection.title' }, From 4224149ab50af6b8b12333fbb67452da32ad504c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:39:16 +0100 Subject: [PATCH 4/5] feat: move Compose to the top of the folder sidebar on desktop and keep one account switcher and one list menu Two mail-app tests clicked the round Compose button on a desktop; their mocked sidebar now draws the Compose button the real one has there. --- components/email/email-list.tsx | 9 ++- .../layout/__tests__/sidebar-compose.test.tsx | 80 +++++++++++++++++++ components/layout/sidebar.tsx | 38 +++++++-- .../mail/__tests__/after-send-hook.test.tsx | 5 +- .../mail/__tests__/mobile-tour-end.test.tsx | 33 +++++++- components/mail/mail-app.tsx | 19 +++-- components/settings/layout-settings.tsx | 16 +--- lib/settings-schema.ts | 6 +- lib/settings-search.ts | 1 - 9 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 components/layout/__tests__/sidebar-compose.test.tsx diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index a744becf7..2f3b142f7 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -28,6 +28,8 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; +import { useIsDesktop } from "@/hooks/use-media-query"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useSettingsStore } from "@/stores/settings-store"; import { useListRowShape } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; @@ -185,6 +187,8 @@ export function EmailList({ const tViewer = useTranslations('email_viewer'); const tSidebar = useTranslations('sidebar'); const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); + const isDesktopPane = useIsDesktop(); + const isEmbeddedList = useIsEmbedded(); const undo = undoLabels(tNotifications); const tRemove = useTranslations('email_list.confirm_remove'); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); @@ -986,10 +990,11 @@ export function EmailList({ ); // Room under the last row for the round Compose button, which would otherwise - // sit on that row's actions (Mountain View has no such button). A spacer and + // sit on that row's actions (Mountain View has no such button, and a desktop + // has Compose in the folder sidebar instead). A spacer and // not padding on the list: padding is a minimum height the list can't go // under, and on a short screen it pushed the rows over the tab bar. - const composeButtonRoom = !mountainViewList ? )} diff --git a/components/mail/__tests__/after-send-hook.test.tsx b/components/mail/__tests__/after-send-hook.test.tsx index e71b1006a..40fa0a406 100644 --- a/components/mail/__tests__/after-send-hook.test.tsx +++ b/components/mail/__tests__/after-send-hook.test.tsx @@ -32,7 +32,10 @@ vi.mock('@/components/email/email-composer', () => ({ ), })); -vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +// On a desktop Compose sits at the head of the folder sidebar (mail-13). +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose?: () => void }) => , +})); vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); diff --git a/components/mail/__tests__/mobile-tour-end.test.tsx b/components/mail/__tests__/mobile-tour-end.test.tsx index 37fabc597..c059d5839 100644 --- a/components/mail/__tests__/mobile-tour-end.test.tsx +++ b/components/mail/__tests__/mobile-tour-end.test.tsx @@ -29,7 +29,10 @@ vi.mock('@/components/email/email-composer', () => ({ return

stub composer

; }, })); -vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +// On a desktop Compose sits at the head of the folder sidebar (mail-13). +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose?: () => void }) => (device.isMobile ? null : ), +})); vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); @@ -64,9 +67,9 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); -const device = vi.hoisted(() => ({ isMobile: true })); +const device = vi.hoisted(() => ({ isMobile: true, isTablet: false })); vi.mock('@/hooks/use-media-query', () => ({ - useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), + useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: device.isTablet, isDesktop: !device.isMobile && !device.isTablet }), useMediaQuery: () => false, useIsDesktop: () => false, })); @@ -109,6 +112,7 @@ beforeEach(() => { }); afterEach(() => { device.isMobile = true; + device.isTablet = false; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -123,6 +127,29 @@ describe('Mail after the tour on a phone', () => { expect(listHidden()).toBe(false); }); + // Usability test 2026-11-04, mail-13: on a desktop the round button sat on + // the list's rows; Compose is at the head of the folder sidebar there. + it('draws the round Compose button on a phone, not on a desktop', () => { + const view = render(); + expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1); + + device.isMobile = false; + view.rerender(); + expect(document.querySelector('[data-tour="compose-button"]')).toBeNull(); + }); + + // mail-27: a tablet drew the header's ⋮ and the list's ⋮ one above the + // other, with the same entries; the header's one has them all now. + it('draws the list ⋮ on a desktop but not on a tablet, under the header ⋮', () => { + device.isMobile = false; + const view = render(); + expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull(); + + device.isTablet = true; + view.rerender(); + expect(screen.queryByTestId('mail-folder-menu')).toBeNull(); + }); + it('keeps an open composer in view when the window narrows to a phone', async () => { device.isMobile = false; useUIStore.setState({ activeView: 'list' }); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 8e082b4d8..35edbd576 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -90,7 +90,7 @@ import { } from "@/components/error"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog"; -import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus"; +import { MailFolderMenu, MailFolderMenuItems, MailSortSubMenu } from "@/components/email/mail-list-menus"; import { ContextMenuItem } from "@/components/ui/context-menu"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; @@ -5492,6 +5492,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { disabled={!client || isManualRefreshing} onClick={() => { close(); void handleManualRefresh(); }} /> + {/* A tablet shows the list's own ⋮ nowhere else (see below). */} + {isTablet && } handleMarkFolderRead(selectedMailbox) : undefined} onMarkAllFoldersRead={handleMarkAllFoldersRead} @@ -5597,8 +5599,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } /> - {/* The folder's actions, as the Mountain View list ⋮ has them. */} - + />} {/* Plugin buttons (top-bar-actions). On phones they sit in the header's overflow menu instead (MobileHeader). */} {!isMobile && ( @@ -5814,9 +5818,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
- {/* Floating Compose Button - on desktop Mountain View puts Compose - at the top of the navigation rail instead. */} - {!mvShell &&
- {showSourceFolder && } + {showSourceFolder && } {scheduledSendLabel ? ( )} {hasAttachment && } - {showSourceFolder && } + {showSourceFolder && } {scheduledSendLabel ? (
- {showSourceFolder && } + {showSourceFolder && } {scheduledSendLabel ? ( ({ FolderRulesSubMenu: () => null })); + +const { MailboxContextMenu } = await import('../mailbox-context-menu'); + +const box = (id: string, name: string, role: string | null, parentId: string | null = null) => + ({ id, name, role, parentId, sortOrder: 1, totalEmails: 2, unreadEmails: 0, myRights: {} }) as unknown as Mailbox; + +function open(mailbox: Mailbox, mailboxes: Mailbox[]) { + render( + , + ); +} + +describe('MailboxContextMenu header', () => { + it('names a system folder the way the sidebar does', () => { + const trash = box('trash', 'Deleted Items', 'trash'); + open(trash, [trash]); + expect(screen.getByTitle('mailboxes.trash')).toBeTruthy(); + expect(screen.queryByText('Deleted Items')).toBeNull(); + }); + + it('keeps the path of a folder of your own', () => { + const parent = box('p', 'Projects', null); + const child = box('c', 'Alpha', null, 'p'); + open(child, [parent, child]); + expect(screen.getByTitle('Projects › Alpha')).toBeTruthy(); + }); +}); diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 827d1d592..c4cf19add 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -3,6 +3,7 @@ import { useTranslations } from "next-intl"; import { Mailbox } from "@/lib/jmap/types"; import { getMailboxPath } from "@/lib/utils"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { ContextMenu, ContextMenuItem, @@ -143,6 +144,7 @@ export function MailboxContextMenu({ notifications, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); + const tSidebar = useTranslations("sidebar"); const tImport = useTranslations("mail_import"); const tExport = useTranslations("mail_export"); // Organisation > Devices > No export hides it; import stays. @@ -245,7 +247,9 @@ export function MailboxContextMenu({ // A compact shared mailbox's row is named after its account, so is its menu. const fullPath = target.sharedAccountId && mailbox.accountName ? mailbox.accountName - : getMailboxPath(mailbox, mailboxes); + : mailbox.role && !mailbox.parentId + ? localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`)) + : getMailboxPath(mailbox, mailboxes); return ( diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts index cda7d2ec1..469bf6ce2 100644 --- a/lib/__tests__/unified-mailbox-search-accounts.test.ts +++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts @@ -70,6 +70,8 @@ describe('searchAcrossAccounts', () => { expect(byId['own-1']).toMatchObject({ accountId: 'login', sourceClientAccountId: 'login', sourceAccountId: 'me', sourceFolder: 'Inbox', + // The role lets the chip say the sidebar's name (usability test 2026-11-04, mail-14). + sourceFolderRole: 'inbox', }); expect(byId['grp-1']).toMatchObject({ accountId: 'group', accountLabel: 'Team', sourceClientAccountId: 'login', sourceAccountId: 'group', diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 6a19f659f..29e0ea8af 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -97,6 +97,8 @@ export interface Email { // views (All Mail, unified, cross-account) so the list can show where each // message lives. Transient/client-only, not part of the JMAP object. sourceFolder?: string; + // Role of that folder, so the list names it as the sidebar does ("Sent", not "Sent Items"). + sourceFolderRole?: string; // Client-only scheduled-send metadata, populated from EmailSubmission/query. scheduledSendAt?: string; emailSubmissionId?: string; diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index de3c74509..fba5aa042 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -65,13 +65,25 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [ * match, or undefined if none of the account's known folders contain it. */ export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined { + return sourceMailboxOf(email, mailboxes)?.name; +} + +/** + * The role of that folder, so the list can name it the way the sidebar does + * (localizeMailboxName): "Sent", not the server's "Sent Items". + */ +export function resolveSourceFolderRole(email: Email, mailboxes: Mailbox[]): string | undefined { + return sourceMailboxOf(email, mailboxes)?.role ?? undefined; +} + +function sourceMailboxOf(email: Email, mailboxes: Mailbox[]): Mailbox | undefined { for (const m of mailboxes) { // All fetch paths now namespace shared emails' mailboxIds to the store id // (`${ownerId}:${origId}`), so matching `m.id` works for own and shared // alike. The `originalId` check stays as a defensive fallback for any email // that still carries a bare owner id. (#281 V3) - if (email.mailboxIds?.[m.id]) return m.name; - if (m.originalId && email.mailboxIds?.[m.originalId]) return m.name; + if (email.mailboxIds?.[m.id]) return m; + if (m.originalId && email.mailboxIds?.[m.originalId]) return m; } return undefined; } @@ -195,6 +207,7 @@ export async function fetchUnifiedEmails( sourceClientAccountId: account.clientAccountId, sourceAccountId: account.jmapAccountId, sourceFolder: resolveSourceFolderName(email, account.mailboxes), + sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes), })); mergedEmails = mergedEmails.concat(decorated); @@ -321,6 +334,7 @@ async function fanOutUnifiedQuery( sourceClientAccountId: account.clientAccountId, sourceAccountId: account.jmapAccountId, sourceFolder: resolveSourceFolderName(email, account.mailboxes), + sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes), })); mergedEmails = mergedEmails.concat(decorated); totalSum += result.total; @@ -467,6 +481,7 @@ async function fanOutCrossQuery( sourceClientAccountId: account.clientAccountId, sourceAccountId: account.jmapAccountId, sourceFolder: resolveSourceFolderName(email, account.mailboxes), + sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes), })); mergedEmails = mergedEmails.concat(decorated); totalSum += result.total; @@ -724,6 +739,7 @@ async function fanOutAccountQuery( sourceClientAccountId: account.clientAccountId, sourceAccountId: account.jmapAccountId, sourceFolder: resolveSourceFolderName(email, account.mailboxes), + sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes), })); mergedEmails = mergedEmails.concat(decorated); totalSum += result.total; diff --git a/locales/ar/common.json b/locales/ar/common.json index 0537b16ed..756a18a7b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "احتساب البريد غير المقروء في كل الحسابات", "description": "الرقم على علامة التبويب وأيقونة التطبيق وشريط النظام يجمع صناديق الوارد لكل الحسابات المسجّل دخولها." - } + }, + "empty_hint": "يمكنك إفراغ مجلد {folder} لحذف جميع الرسائل نهائيًا." }, "protocol_handlers": { "title": "التطبيقات الافتراضية", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0b3ee80b6..24f758b50 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Compta el correu no llegit de tots els comptes", "description": "El número de la pestanya, la icona de l'aplicació i la safata suma les safates d'entrada de tots els comptes amb la sessió iniciada." - } + }, + "empty_hint": "Podeu buidar la carpeta {folder} per eliminar definitivament tots els missatges." }, "protocol_handlers": { "title": "Aplicacions predeterminades", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5929cbfd9..b880a8f63 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Počítat nepřečtenou poštu ve všech účtech", "description": "Číslo na kartě, na ikoně aplikace a v oznamovací oblasti sčítá doručené složky všech přihlášených účtů." - } + }, + "empty_hint": "Můžete vyprázdnit složku {folder} a trvale odstranit všechny zprávy." }, "protocol_handlers": { "title": "Výchozí aplikace", diff --git a/locales/da/common.json b/locales/da/common.json index 6f3733da6..c82d1d889 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Tæl ulæst post i alle konti", "description": "Tallet på fanen, appikonet og i proceslinjen lægger indbakkerne for alle loggede-ind konti sammen." - } + }, + "empty_hint": "Du kan tømme mappen {folder} for permanent at fjerne alle beskeder." }, "protocol_handlers": { "title": "Standardapps", diff --git a/locales/de/common.json b/locales/de/common.json index 67ff209b4..2ef9913d6 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Ungelesene Mails aller Konten zählen", "description": "Die Zahl am Tab, am App-Symbol und im Infobereich zählt die Posteingänge aller angemeldeten Konten zusammen." - } + }, + "empty_hint": "Sie können den Ordner {folder} leeren, um alle Nachrichten dauerhaft zu entfernen." }, "protocol_handlers": { "title": "Standard-Apps", diff --git a/locales/en/common.json b/locales/en/common.json index d32a34bc9..2d9077ef4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Count unread mail in all accounts", "description": "The number on the tab, the app icon and the tray adds up the inboxes of every signed-in account." - } + }, + "empty_hint": "You can empty the {folder} folder to permanently remove all messages." }, "protocol_handlers": { "title": "Default apps", diff --git a/locales/es/common.json b/locales/es/common.json index f7f50067d..5a2e71541 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Contar el correo sin leer de todas las cuentas", "description": "El número de la pestaña, el icono de la aplicación y la bandeja del sistema suma las bandejas de entrada de todas las cuentas con sesión iniciada." - } + }, + "empty_hint": "Puede vaciar la carpeta {folder} para eliminar permanentemente todos los mensajes." }, "protocol_handlers": { "title": "Aplicaciones predeterminadas", diff --git a/locales/et/common.json b/locales/et/common.json index a40644ac4..0c9ba74e5 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Loe kõigi kontode lugemata kirjad kokku", "description": "Vahekaardi, rakenduse ikooni ja teavitusala arv liidab kokku kõigi sisselogitud kontode postkastid." - } + }, + "empty_hint": "Kausta {folder} tühjendades saad kõik kirjad jäädavalt eemaldada." }, "protocol_handlers": { "title": "Vaikerakendused", diff --git a/locales/fa/common.json b/locales/fa/common.json index 157117c51..60a4cd545 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "شمارش نامه‌های خوانده‌نشده همه حساب‌ها", "description": "عدد روی زبانه، نماد برنامه و سینی سیستم، صندوق‌های ورودی همه حساب‌های واردشده را با هم جمع می‌کند." - } + }, + "empty_hint": "می‌توانید پوشه {folder} را خالی کنید تا همه پیام‌ها برای همیشه حذف شوند." }, "protocol_handlers": { "title": "برنامه‌های پیش‌فرض", diff --git a/locales/fr/common.json b/locales/fr/common.json index c3829f87e..d633c0666 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Compter les messages non lus de tous les comptes", "description": "Le nombre sur l'onglet, l'icône de l'application et la zone de notification additionne les boîtes de réception de tous les comptes connectés." - } + }, + "empty_hint": "Vous pouvez vider le dossier {folder} pour supprimer définitivement tous les messages." }, "protocol_handlers": { "title": "Applications par défaut", diff --git a/locales/he/common.json b/locales/he/common.json index 7ad373151..e9b4ae79f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -219,7 +219,8 @@ "badge_all_accounts": { "label": "ספירת דואר שלא נקרא בכל החשבונות", "description": "המספר בכרטיסייה, בסמל האפליקציה ובמגש המערכת מחבר את תיבות הדואר הנכנס של כל החשבונות המחוברים." - } + }, + "empty_hint": "אפשר לרוקן את התיקייה {folder} כדי להסיר לצמיתות את כל ההודעות." }, "sidebar_apps": { "modal_title": "אפליקציות בסרגל הצד", diff --git a/locales/hu/common.json b/locales/hu/common.json index 67fc90fa7..e70075d52 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Az összes fiók olvasatlan leveleinek számlálása", "description": "A lapon, az alkalmazásikonon és a tálcán látható szám az összes bejelentkezett fiók beérkezett leveleit adja össze." - } + }, + "empty_hint": "A(z) {folder} mappa ürítésével véglegesen eltávolíthatod az összes üzenetet." }, "protocol_handlers": { "title": "Alapértelmezett alkalmazások", diff --git a/locales/it/common.json b/locales/it/common.json index 29326a99f..6f3e59484 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Conta la posta non letta di tutti gli account", "description": "Il numero sulla scheda, sull'icona dell'app e nell'area di notifica somma le caselle di posta in arrivo di tutti gli account collegati." - } + }, + "empty_hint": "Puoi svuotare la cartella {folder} per rimuovere definitivamente tutti i messaggi." }, "protocol_handlers": { "title": "App predefinite", diff --git a/locales/ja/common.json b/locales/ja/common.json index 98020f31e..7477c80fa 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "すべてのアカウントの未読を数える", "description": "タブ、アプリのアイコン、通知領域の数字に、サインイン中のすべてのアカウントの受信トレイを合計して表示します。" - } + }, + "empty_hint": "{folder}フォルダを空にして、すべてのメールを完全に削除できます。" }, "protocol_handlers": { "title": "既定のアプリ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 897e453ef..0b10f8f86 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "모든 계정의 읽지 않은 메일 세기", "description": "탭, 앱 아이콘, 알림 영역의 숫자에 로그인한 모든 계정의 받은편지함을 합산해 표시해요." - } + }, + "empty_hint": "{folder} 폴더를 비워 모든 메시지를 영구적으로 삭제할 수 있어요." }, "protocol_handlers": { "title": "기본 앱", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2599cc500..2a6ed6181 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Skaitīt nelasītās vēstules visos kontos", "description": "Skaitlis cilnē, lietotnes ikonā un paziņojumu apgabalā saskaita visu pierakstīto kontu iesūtnes." - } + }, + "empty_hint": "Jūs varat iztīrīt mapi {folder}, lai neatgriezeniski dzēstu visus ziņojumus." }, "protocol_handlers": { "title": "Noklusējuma lietotnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 68967ba2e..d6f2e38c4 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Бүх бүртгэлийн уншаагүй захидлыг тоолох", "description": "Таб, аппын дүрс болон мэдэгдлийн хэсэг дээрх тоо нь нэвтэрсэн бүх бүртгэлийн ирсэн захидлыг нэгтгэнэ." - } + }, + "empty_hint": "Та бүх мессежийг бүрмөсөн устгахын тулд {folder} хавтасыг хоослох боломжтой." }, "protocol_handlers": { "title": "Өгөгдмөл програмууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index bcda16c1d..9929438ad 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Tell ulest e-post i alle kontoer", "description": "Tallet på fanen, appikonet og i systemstatusfeltet legger sammen innboksene til alle påloggede kontoer." - } + }, + "empty_hint": "Du kan tømme mappen {folder} for å fjerne alle meldinger permanent." }, "protocol_handlers": { "title": "Standardapper", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9eb958686..7ffcf88d9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Ongelezen mail van alle accounts tellen", "description": "Het getal op het tabblad, het app-pictogram en in het systeemvak telt de inboxen van alle aangemelde accounts bij elkaar op." - } + }, + "empty_hint": "U kunt de map {folder} legen om alle berichten permanent te verwijderen." }, "protocol_handlers": { "title": "Standaardapps", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6712c74bc..26d66cff4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Licz nieprzeczytaną pocztę ze wszystkich kont", "description": "Liczba na karcie, ikonie aplikacji i w zasobniku sumuje skrzynki odbiorcze wszystkich zalogowanych kont." - } + }, + "empty_hint": "Możesz opróżnić folder {folder}, aby trwale usunąć wszystkie wiadomości." }, "protocol_handlers": { "title": "Aplikacje domyślne", diff --git a/locales/pt/common.json b/locales/pt/common.json index dfc82a03e..2338788a0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Contar os e-mails não lidos de todas as contas", "description": "O número na aba, no ícone do app e na bandeja do sistema soma as caixas de entrada de todas as contas conectadas." - } + }, + "empty_hint": "Você pode esvaziar a pasta {folder} para remover permanentemente todas as mensagens." }, "protocol_handlers": { "title": "Aplicativos padrão", diff --git a/locales/ro/common.json b/locales/ro/common.json index 5f007a176..e0e1898c3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Numără mesajele necitite din toate conturile", "description": "Numărul de pe filă, de pe pictograma aplicației și din zona de notificare adună căsuțele de primire ale tuturor conturilor conectate." - } + }, + "empty_hint": "Puteți goli folderul {folder} pentru a șterge definitiv toate mesajele." }, "protocol_handlers": { "title": "Aplicații implicite", diff --git a/locales/ru/common.json b/locales/ru/common.json index be20722d5..0c61cb1f0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Считать непрочитанные письма во всех аккаунтах", "description": "Число на вкладке, на значке приложения и в трее складывает входящие всех аккаунтов, в которые выполнен вход." - } + }, + "empty_hint": "Вы можете очистить папку «{folder}», чтобы навсегда удалить все сообщения." }, "protocol_handlers": { "title": "Приложения по умолчанию", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8e05da256..b9fe1746b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Počítať neprečítanú poštu vo všetkých účtoch", "description": "Číslo na karte, na ikone aplikácie a v oblasti oznámení sčítava doručenú poštu všetkých prihlásených účtov." - } + }, + "empty_hint": "Môžete vyprázdniť priečinok {folder} a natrvalo odstrániť všetky správy." }, "protocol_handlers": { "title": "Predvolené aplikácie", diff --git a/locales/th/common.json b/locales/th/common.json index a65253edd..99f744624 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -350,7 +350,8 @@ "badge_all_accounts": { "label": "นับอีเมลที่ยังไม่ได้อ่านของทุกบัญชี", "description": "ตัวเลขบนแท็บ ไอคอนแอป และถาดระบบ จะรวมกล่องจดหมายเข้าของทุกบัญชีที่ลงชื่อเข้าใช้" - } + }, + "empty_hint": "คุณสามารถล้างโฟลเดอร์{folder}เพื่อลบข้อความทั้งหมดอย่างถาวร" }, "language": { "title": "ภาษา", diff --git a/locales/tr/common.json b/locales/tr/common.json index 478699e25..9a6778286 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Tüm hesaplardaki okunmamış postaları say", "description": "Sekmedeki, uygulama simgesindeki ve sistem tepsisindeki sayı, oturum açılmış tüm hesapların gelen kutularını toplar." - } + }, + "empty_hint": "Tüm iletileri kalıcı olarak kaldırmak için {folder} klasörünü boşaltabilirsiniz." }, "protocol_handlers": { "title": "Varsayılan uygulamalar", diff --git a/locales/uk/common.json b/locales/uk/common.json index a968e8ad0..f1e6e0156 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Рахувати непрочитані листи в усіх облікових записах", "description": "Число на вкладці, на значку застосунку та в треї підсумовує вхідні всіх облікових записів, у які виконано вхід." - } + }, + "empty_hint": "Ви можете очистити папку «{folder}», щоб остаточно видалити всі повідомлення." }, "protocol_handlers": { "title": "Програми за замовчуванням", diff --git a/locales/vi/common.json b/locales/vi/common.json index 664a14d64..85b0c9e78 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "Đếm thư chưa đọc của mọi tài khoản", "description": "Con số trên tab, biểu tượng ứng dụng và khay hệ thống cộng hộp thư đến của mọi tài khoản đã đăng nhập." - } + }, + "empty_hint": "Bạn có thể làm trống thư mục {folder} để xóa vĩnh viễn tất cả thư." }, "protocol_handlers": { "title": "Ứng dụng mặc định", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c613c620a..037a42eb3 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "計算所有帳號的未讀郵件", "description": "分頁、應用程式圖示和系統匣上的數字會合計所有已登入帳號的收件匣。" - } + }, + "empty_hint": "您可以清空「{folder}」信件匣,永久刪除其中的所有郵件。" }, "protocol_handlers": { "title": "預設應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index 938b5c782..b2db22953 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -220,7 +220,8 @@ "badge_all_accounts": { "label": "统计所有账户的未读邮件", "description": "标签页、应用图标和托盘上的数字会合计所有已登录账户的收件箱。" - } + }, + "empty_hint": "您可以清空“{folder}”文件夹以永久删除所有邮件。" }, "protocol_handlers": { "title": "默认应用",