diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index d13164e5..542a9bd6 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -3194,7 +3194,17 @@ export default function Home() { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} - onDelete={() => handleDelete()} + onDelete={() => { + // Deleting the open message returns to the list (Gmail-style), + // not the next email — unless the user turned the setting off. + // Deselect first so the store's remove-and-advance sees no + // selection and doesn't auto-open the next message. + const target = selectedEmail; + if (useSettingsStore.getState().returnToListAfterAction) { + handleMobileBack(); + } + handleDelete(target); + }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} onSetColorTag={handleSetColorTag} @@ -3203,6 +3213,12 @@ export default function Home() { onMarkAsRead={async (emailId, read) => { if (client) { await markAsRead(client, emailId, read); + // Marking the open message unread returns to the list + // (Gmail-style, gated on returnToListAfterAction). Staying + // in the reading pane would just re-mark it read on view. + if (!read && useSettingsStore.getState().returnToListAfterAction) { + handleMobileBack(); + } } }} onDownloadAttachment={handleDownloadAttachment} diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index c86eec9e..d997b94c 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -121,6 +121,7 @@ export function LayoutSettings() { const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const accounts = useAccountStore(s => s.accounts); + const activeAccountId = useAccountStore(s => s.activeAccountId); const mailboxes = useEmailStore(s => s.mailboxes); const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]); const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled'); @@ -131,20 +132,32 @@ export function LayoutSettings() { { setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' }, ] as const; - // Own (non-shared) folders and the current All Mail selection. `null` = - // never configured, which defaults to all non-special (no-role) folders. + // Own (non-shared) folders and the active account's All Mail selection. The + // selection is per account: a missing entry = never configured, which + // defaults to all no-role folders; an explicit [] = no folders. const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]); + const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined; const allMailSelected = new Set( - allMailFolderIds === null + currentAllMailEntry === undefined ? ownMailboxes.filter(m => !m.role).map(m => m.id) - : allMailFolderIds + : currentAllMailEntry ); const toggleAllMailFolder = (id: string) => { + if (!activeAccountId) return; const next = new Set(allMailSelected); if (next.has(id)) next.delete(id); else next.add(id); - updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.id)); + updateSetting('allMailFolderIds', { + ...allMailFolderIds, + [activeAccountId]: ownMailboxes.filter(m => next.has(m.id)).map(m => m.id), + }); }; + // Name the account the selection applies to, but only when more than one is + // logged in (otherwise it's unambiguous). + const activeAccount = accounts.find(a => a.id === activeAccountId); + const allMailAccountHint = accounts.length > 1 && activeAccount + ? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email }) + : null; return ( @@ -270,6 +283,9 @@ export function LayoutSettings() {
{t('all_mail.folders_label')}
{t('all_mail.folders_description')}
+ {allMailAccountHint && ( +
{allMailAccountHint}
+ )}
{ownMailboxes.length === 0 ? (

{t('all_mail.no_folders')}

diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index f74aa045..1a54f8f1 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -22,6 +22,7 @@ export function ReadingSettings() { markAsReadDelay, deleteAction, permanentlyDeleteJunk, + returnToListAfterAction, showPreview, mailLayout, disableThreading, @@ -176,6 +177,13 @@ export function ReadingSettings() { /> + + updateSetting('returnToListAfterAction', checked)} + /> + + {!isSettingHidden('showPreview') && ( { expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo'); }); - it('strips the Outlook [N] counter and Eudora *N counter', () => { + it('strips the Outlook [N] and Eudora *N counters', () => { expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo'); expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo'); - expect(stripSubjectPrefixes('Re*: foo')).toBe('foo'); }); it('is case-insensitive and idempotent', () => { @@ -21,47 +20,37 @@ describe('stripSubjectPrefixes', () => { expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo'); }); - it('strips a Cyrillic reply token', () => { + it('strips a Cyrillic token and an ASCII-colon Chinese token', () => { expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo'); - }); - - it('strips a Chinese token followed by an ASCII colon', () => { expect(stripSubjectPrefixes('回复: foo')).toBe('foo'); }); - it('CHARACTERISATION: does NOT strip a token followed by a full-width colon', () => { - // The colon in the regex is ASCII ":"; a full-width ":" (U+FF1A), as some - // CJK mail clients emit, is left untouched. Likely a bug — see follow-ups. - expect(stripSubjectPrefixes('回复:foo')).toBe('回复:foo'); + it('strips a token followed by a full-width colon (CJK clients)', () => { + expect(stripSubjectPrefixes('回复:foo')).toBe('foo'); + expect(stripSubjectPrefixes('回覆:foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re:foo')).toBe('foo'); }); - it('does NOT strip a bare single-letter "R:" (would eat real subjects)', () => { + it('still does not strip a bare single-letter "R:"', () => { expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024'); }); - it('returns "" for empty / null / undefined', () => { + it('returns "" for empty / null / undefined and leaves clean subjects alone', () => { expect(stripSubjectPrefixes('')).toBe(''); expect(stripSubjectPrefixes(null)).toBe(''); expect(stripSubjectPrefixes(undefined)).toBe(''); - }); - - it('leaves a prefix-free subject untouched', () => { expect(stripSubjectPrefixes('foo')).toBe('foo'); }); }); describe('buildReplySubject / buildForwardSubject', () => { - it('replaces an existing prefix chain with the given prefix', () => { - expect(buildReplySubject('AW: WG: foo', 'Re:')).toBe('Re: foo'); + it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => { + expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo'); expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo'); }); - it('prepends the prefix to a prefix-free subject', () => { + it('prepends to a clean subject and returns the bare prefix for empty input', () => { expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo'); - }); - - it('returns just the bare prefix for an empty subject', () => { expect(buildReplySubject('', 'AW:')).toBe('AW:'); - expect(buildForwardSubject(null, 'Fwd:')).toBe('Fwd:'); }); }); diff --git a/lib/subject-prefix.ts b/lib/subject-prefix.ts index 5ca0d5aa..766155a7 100644 --- a/lib/subject-prefix.ts +++ b/lib/subject-prefix.ts @@ -64,8 +64,10 @@ function buildPrefixRegex(tokens: string[]): RegExp { // Sort by length DESC so longer tokens (e.g. "Пересл") win over their // shorter prefixes (e.g. "Пер") during alternation matching. escaped.sort((a, b) => b.length - a.length); + // Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK + // mail clients emit after a localized prefix (e.g. "回复:foo"). return new RegExp( - `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`, + `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`, "i", ); } diff --git a/locales/cs/common.json b/locales/cs/common.json index 9244eb69..0c96d96f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Vypnuto", "seconds": "{seconds} sekund", "unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/da/common.json b/locales/da/common.json index 143066f4..9e696fee 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -925,6 +925,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1245,6 +1246,10 @@ "off": "Fra", "seconds": "{seconds} sekunder", "unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/de/common.json b/locales/de/common.json index b53b64d4..fddc8a3c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Gilt für {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Aus", "seconds": "{seconds} Sekunden", "unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/en/common.json b/locales/en/common.json index e77f6a19..3e7db119 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -925,6 +925,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1245,6 +1246,10 @@ "off": "Off", "seconds": "{seconds} seconds", "unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/es/common.json b/locales/es/common.json index ef462458..f75b4220 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Desactivado", "seconds": "{seconds} segundos", "unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/fr/common.json b/locales/fr/common.json index ca0b6904..76a2312f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Désactivé", "seconds": "{seconds} secondes", "unsupported": "Le compte actuel n’annonce pas la prise en charge de l’envoi différé. Le réglage reste enregistré pour d’autres comptes." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/hu/common.json b/locales/hu/common.json index fa478aa6..754ee99e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -925,6 +925,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1245,6 +1246,10 @@ "off": "Kikapcsolva", "seconds": "{seconds} másodperc", "unsupported": "A jelenlegi fiók nem támogatja a késleltetett küldést. A beállítás más fiókokhoz elmentésre kerül." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/it/common.json b/locales/it/common.json index e8239822..091ab27c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Disattivato", "seconds": "{seconds} secondi", "unsupported": "L’account corrente non dichiara il supporto all’invio ritardato. L’impostazione resta salvata per altri account." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 78a74926..e504cbf5 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "オフ", "seconds": "{seconds} 秒", "unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。" + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 302aba24..e18b4c2c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "끔", "seconds": "{seconds}초", "unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/lv/common.json b/locales/lv/common.json index a87ae65f..1d58b448 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Izslēgts", "seconds": "{seconds} sekundes", "unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 399163a2..82ff3e60 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Uit", "seconds": "{seconds} seconden", "unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 09154a32..e5597797 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Wyłączone", "seconds": "{seconds} sekund", "unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/pt/common.json b/locales/pt/common.json index da68af86..7844d950 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Desativado", "seconds": "{seconds} segundos", "unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/ro/common.json b/locales/ro/common.json index f6817394..a74b4405 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -925,6 +925,7 @@ "description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.", "folders_label": "Dosare în „Toate mesajele”", "folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.", + "account_hint": "Se aplică pentru {account}.", "no_folders": "Nu sunt disponibile foldere." }, "colorful_sidebar_icons": { @@ -1245,6 +1246,10 @@ "off": "Oprit", "seconds": "{seconds} secunde", "unsupported": "Contul curent nu indică faptul că acceptă trimiterea amânată. Setarea este totuși salvată pentru alte conturi." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 8e190757..f0523221 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Выкл.", "seconds": "{seconds} сек.", "unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/tr/common.json b/locales/tr/common.json index c4d55e91..922075d5 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Kapalı", "seconds": "{seconds} saniye", "unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/uk/common.json b/locales/uk/common.json index d5e18dd9..57c192d7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "Вимкнено", "seconds": "{seconds} с", "unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів." + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 7bb1e41e..f672f599 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { @@ -1242,6 +1243,10 @@ "off": "关闭", "seconds": "{seconds} 秒", "unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。" + }, + "return_to_list_after_action": { + "label": "Return to list after delete or mark unread", + "description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message." } }, "composer": { diff --git a/stores/__tests__/settings-store-all-mail.test.ts b/stores/__tests__/settings-store-all-mail.test.ts new file mode 100644 index 00000000..616a8b35 --- /dev/null +++ b/stores/__tests__/settings-store-all-mail.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../settings-store'; + +describe('settings-store per-account allMailFolderIds', () => { + beforeEach(() => { + useSettingsStore.setState({ allMailFolderIds: {} }); + }); + + it('defaults to an empty record (every account "not configured")', () => { + expect(useSettingsStore.getState().allMailFolderIds).toEqual({}); + }); + + it('keeps each account selection independent', () => { + useSettingsStore.setState({ + allMailFolderIds: { 'acct-1': ['inbox', 'archive'], 'acct-2': ['sent'] }, + }); + const map = useSettingsStore.getState().allMailFolderIds; + expect(map['acct-1']).toEqual(['inbox', 'archive']); + expect(map['acct-2']).toEqual(['sent']); + // A third account remains unconfigured (no entry). + expect(map['acct-3']).toBeUndefined(); + }); + + it('distinguishes explicit-empty ([] = no folders) from not-configured (undefined)', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': [] } }); + const map = useSettingsStore.getState().allMailFolderIds; + expect(map['acct-1']).toEqual([]); // explicit "no folders" + expect(map['acct-2']).toBeUndefined(); // never configured + }); + + describe('importSettings legacy-shape guard', () => { + it('ignores a legacy global array shape', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } }); + const ok = useSettingsStore.getState().importSettings( + JSON.stringify({ allMailFolderIds: ['inbox', 'sent'] }), + ); + expect(ok).toBe(true); + // unchanged - the array shape was rejected + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] }); + }); + + it('ignores a null legacy value', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } }); + useSettingsStore.getState().importSettings(JSON.stringify({ allMailFolderIds: null })); + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] }); + }); + + it('accepts a proper per-account record', () => { + useSettingsStore.getState().importSettings( + JSON.stringify({ allMailFolderIds: { 'acct-9': ['inbox', 'spam'] } }), + ); + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-9': ['inbox', 'spam'] }); + }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 4f943d68..7e3b23ab 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -331,15 +331,19 @@ function resolveActionMailboxes(): Mailbox[] { /** * Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the - * active/viewing account. Honors the per-user `allMailFolderIds` setting; when - * unset (null) it defaults to every non-special (no-role) folder. Shared + * active/viewing account. Honors that account's `allMailFolderIds` entry; when + * not configured it defaults to every non-special (no-role) folder. Shared * folders are excluded - All Mail is scoped to a single account. Returns * JMAP-side ids (originalId for namespaced mailboxes). */ function resolveAllMailJmapIds(): string[] { const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared); - const configured = useSettingsStore.getState().allMailFolderIds; - const selected = configured === null + // Per-account selection: read the entry for the account the view is scoped to + // (the Pro viewing override, else the global active account). A missing entry + // = "not configured" -> all no-role folders; an explicit [] = no folders. + const accountId = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; + const configured = accountId ? useSettingsStore.getState().allMailFolderIds[accountId] : undefined; + const selected = configured === undefined ? mailboxes.filter((mb) => !mb.role) : mailboxes.filter((mb) => configured.includes(mb.id)); return selected.map((mb) => mb.originalId || mb.id); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index f119fda7..3a13aa34 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -15,6 +15,11 @@ const syncLog = (...args: unknown[]) => console.log('[SETTINGS_SYNC]', ...args); const syncWarn = (...args: unknown[]) => console.warn('[SETTINGS_SYNC]', ...args); const syncError = (...args: unknown[]) => console.error('[SETTINGS_SYNC]', ...args); +/** True for a non-null, non-array plain object (the allMailFolderIds map shape). */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + // Settings sync state (module-level, not persisted) let syncEnabled = false; let syncUsername: string | null = null; @@ -137,6 +142,7 @@ interface SettingsState { markAsReadDelay: number; // milliseconds (0 = instant, -1 = never) deleteAction: DeleteAction; permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash + returnToListAfterAction: boolean; // After delete / mark-unread in an open message, return to the list instead of opening the next message showPreview: boolean; mailLayout: MailLayout; emailsPerPage: number; @@ -226,10 +232,10 @@ interface SettingsState { enableCrossStarredView: boolean; enableCrossAllView: boolean; - // Folder ids merged into the virtual "All Mail" mailbox. `null` = never - // configured, in which case the view defaults to all non-special (no-role) - // folders of the active account. - allMailFolderIds: string[] | null; + // Per-account "All Mail" folder selection, keyed by AccountEntry.id. A + // missing entry = "not configured" -> defaults to every no-role folder; an + // explicit [] = "no folders". (Replaced the legacy global string[] | null.) + allMailFolderIds: Record; // Email Display disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation @@ -339,6 +345,7 @@ const DEFAULT_SETTINGS = { markAsReadDelay: 0, // Instant deleteAction: 'trash' as DeleteAction, permanentlyDeleteJunk: false, + returnToListAfterAction: true, showPreview: true, mailLayout: 'split' as MailLayout, emailsPerPage: 50, @@ -416,7 +423,7 @@ const DEFAULT_SETTINGS = { // All Mail view (gated) enableAllMailView: false, - allMailFolderIds: null as string[] | null, + allMailFolderIds: {} as Record, enableCrossUnreadView: false, enableCrossStarredView: false, @@ -546,6 +553,7 @@ export const useSettingsStore = create()( firstDayOfWeek: state.firstDayOfWeek, markAsReadDelay: state.markAsReadDelay, deleteAction: state.deleteAction, + returnToListAfterAction: state.returnToListAfterAction, showPreview: state.showPreview, mailLayout: state.mailLayout, emailsPerPage: state.emailsPerPage, @@ -646,6 +654,11 @@ export const useSettingsStore = create()( set({ sendDelaySeconds: 0 }); return; } + // Ignore a legacy global allMailFolderIds (string[] | null) or any + // non-record value - this build keys it per account. + if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) { + return; + } if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } @@ -836,7 +849,7 @@ export const useSettingsStore = create()( }), { name: 'settings-storage', - version: 4, + version: 5, migrate: (persisted, version) => { const state = persisted as Record; if (version < 2 && state.listDensity) { @@ -856,11 +869,25 @@ export const useSettingsStore = create()( if (version < 4) { state.dateFormat = 'smart'; } + // v5: allMailFolderIds went from a global `string[] | null` to a + // per-account `Record`. The legacy global list + // can't be attributed to a specific account here (the active account + // isn't known at migrate time), so it's dropped - each account starts + // "not configured" (defaults to all no-role folders). + if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } return state as unknown as SettingsState; }, onRehydrateStorage: () => { return (state) => { if (state) { + // Defensive: a legacy global array or any non-record value (e.g. + // synced from an older client) is coerced to an empty map so + // per-account consumers never see a non-record. + if (!isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } applyFontSize(state.fontSize); applyDensity(state.density); applyAnimations(state.animationsEnabled);