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/email/__tests__/email-list-empty-hint.test.tsx b/components/email/__tests__/email-list-empty-hint.test.tsx new file mode 100644 index 000000000..9dd8ae9a9 --- /dev/null +++ b/components/email/__tests__/email-list-empty-hint.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { JMAPClient } from '@/lib/jmap/client'; +import { useEmailStore } from '@/stores/email-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { EmailList } from '../email-list'; + +// Usability test 2026-11-04, mail-14: the Spam folder's banner said "empty the +// Junk folder", the server's name, while the sidebar calls it Spam. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key} ${JSON.stringify(values)}` : key), + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ stalwartFeaturesEnabled: false, isLoading: false }) })); + +const initialEmail = useEmailStore.getState(); +const initialAuth = useAuthStore.getState(); + +function mailbox(id: string, name: string, role: string): Mailbox { + return { id, name, role, accountId: 'c', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, + myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true } } as Mailbox; +} + +function show(selected: string) { + const emails = [{ id: 'm1', threadId: 't1', blobId: 'b1', mailboxIds: { [selected]: true }, keywords: {}, subject: 'Hello', + receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'someone@example.org' }], size: 1, hasAttachment: false } as Email]; + useEmailStore.setState({ + emails, + mailboxes: [mailbox('junk', 'Junk', 'junk'), mailbox('trash', 'Deleted Items', 'trash')], + selectedMailbox: selected, + totalEmails: 1, + }); + return render(); +} + +beforeEach(() => { + const client = JMAPClient.withBearer('https://list.example.invalid', 'synthetic', 'someone@example.org'); + Object.assign(client, { apiUrl: 'https://list.example.invalid/jmap', accountId: 'c' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ methodResponses: [] })))); + useAuthStore.setState({ activeAccountId: 'acc', client, getClientForAccount: () => client } as never); + useEmailStore.setState(initialEmail, true); +}); + +afterEach(() => { + useAuthStore.setState(initialAuth, true); + vi.unstubAllGlobals(); +}); + +describe('EmailList empty-folder banner', () => { + it('names Spam as the sidebar does', () => { + show('junk'); + expect(screen.getByText('empty_hint {"folder":"mailboxes.spam"}')).toBeTruthy(); + expect(screen.queryByText(/Junk/)).toBeNull(); + }); + + it('names Trash as the sidebar does', () => { + show('trash'); + expect(screen.getByText('empty_hint {"folder":"mailboxes.trash"}')).toBeTruthy(); + }); +}); diff --git a/components/email/__tests__/thread-list-item-source-folder.test.tsx b/components/email/__tests__/thread-list-item-source-folder.test.tsx index cea7f7705..4b7575ea4 100644 --- a/components/email/__tests__/thread-list-item-source-folder.test.tsx +++ b/components/email/__tests__/thread-list-item-source-folder.test.tsx @@ -95,6 +95,14 @@ describe('ThreadListItem folder chip in search results (#1172)', () => { expect(screen.getByTitle('Archive')).toBeTruthy(); }); + // Usability test 2026-11-04, mail-14: the chip said the server's "Sent Items". + it('names a folder with a role the way the sidebar does', () => { + useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' }); + renderThread(emails().map((e) => ({ ...e, sourceFolder: 'Sent Items', sourceFolderRole: 'sent' }))); + expect(screen.getByTitle('mailboxes.sent')).toBeTruthy(); + expect(screen.queryByTitle('Sent Items')).toBeNull(); + }); + it('shows the folder when the search covers all folders', () => { useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: SEARCH_SCOPE_ALL_FOLDERS }); renderThread(emails()); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ae2cabbe4..05d7ba06c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -33,6 +33,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"; @@ -43,6 +45,7 @@ import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEm import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { toast } from "@/stores/toast-store"; import { confirmMailRemoval } from "@/lib/mail-removal-confirm"; import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; @@ -196,7 +199,10 @@ export function EmailList({ const tViewer = useTranslations('email_viewer'); const tSidebar = useTranslations('sidebar'); const tListExtra = useTranslations('mail_list_extra'); + const tUnread = useTranslations('account_unread'); 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 }))); @@ -1074,10 +1080,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 ?