From 64da27061dde4fe7a3a7d632c5864c7649e12c6e Mon Sep 17 00:00:00 2001 From: unknown <1721261724@qq.com> Date: Mon, 27 Jul 2026 00:07:44 +0800 Subject: [PATCH 01/11] feat: add conversation display modes --- backend/src/routes/auth.js | 7 ++++-- backend/src/utils/conversationMode.js | 5 ++++ backend/src/utils/conversationMode.test.js | 12 ++++++++++ frontend/src/store/index.js | 26 +++++++++++++-------- frontend/src/utils/conversationMode.js | 12 ++++++++++ frontend/src/utils/conversationMode.test.js | 18 ++++++++++++++ 6 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 backend/src/utils/conversationMode.js create mode 100644 backend/src/utils/conversationMode.test.js create mode 100644 frontend/src/utils/conversationMode.js create mode 100644 frontend/src/utils/conversationMode.test.js diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index e9d69b9a..f0741362 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -16,6 +16,7 @@ import { sendSystemEmail } from '../services/mailer.js'; import { invalidateGlobalCategorizationCache } from '../services/categorizer.js'; import { sanitizeGtdPrefs } from '../utils/gtdPrefs.js'; import { sanitizeRightSidebarPrefs } from '../utils/rightSidebarPrefs.js'; +import { sanitizeConversationMode } from '../utils/conversationMode.js'; import { redisClient } from '../services/redis.js'; import { consume as rlConsume, reset as rlReset } from '../services/rateLimiter.js'; @@ -755,7 +756,7 @@ router.patch('/preferences', async (req, res) => { if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' }); const { theme, font, layout, notificationSound, pageSize, scrollMode, syncInterval, blockRemoteImages, imageWhitelist, shortcuts, hiddenFolders, language, - threadedView, plaintextEmail, hoverQuickActions, swipeActions, + threadedView, conversationMode, plaintextEmail, hoverQuickActions, swipeActions, expandedAccounts, collapsedFolders, favoriteFolders, recentFolders, fontSize, showAppBadge, showFaviconBadge, replyDefault, sidebarWidth, categorizationEnabled, markReadBehavior, markReadDelay, aiActions, @@ -781,6 +782,7 @@ router.patch('/preferences', async (req, res) => { const markReadBehaviorVal = ['immediate', 'delay', 'manual'].includes(markReadBehavior) ? markReadBehavior : null; const markReadDelayVal = (() => { const n = parseInt(markReadDelay); return (n >= 1 && n <= 10) ? String(n) : null; })(); const autoLockMinutesVal = [0, 1, 5, 15, 30].includes(Number(autoLockMinutes)) ? String(Number(autoLockMinutes)) : null; + const conversationModeVal = sanitizeConversationMode(conversationMode); // Folder-structure sync cadence in seconds; 0 = never. const folderSyncIntervalVal = folderSyncInterval != null && [0, 900, 1800, 3600].includes(Number(folderSyncInterval)) ? String(Number(folderSyncInterval)) : null; // User-defined AI actions: bound the array and each field so the JSONB can't grow unbounded. @@ -833,6 +835,7 @@ router.patch('/preferences', async (req, res) => { || CASE WHEN $36::boolean IS NOT NULL THEN jsonb_build_object('showMobileAvatars', $36::boolean) ELSE '{}'::jsonb END || CASE WHEN $37::boolean IS NOT NULL THEN jsonb_build_object('gravatarAvatars', $37::boolean) ELSE '{}'::jsonb END || CASE WHEN $38::text IS NOT NULL THEN jsonb_build_object('folderSyncInterval', $38::text) ELSE '{}'::jsonb END + || CASE WHEN $39::text IS NOT NULL THEN jsonb_build_object('conversationMode', $39::text) ELSE '{}'::jsonb END WHERE id = $1 `, [req.session.userId, theme ?? null, font ?? null, layout ?? null, notificationSound ?? null, pageSize ?? null, scrollMode ?? null, syncInterval ?? null, @@ -842,7 +845,7 @@ router.patch('/preferences', async (req, res) => { showAppBadge ?? null, showFaviconBadge ?? null, replyDefaultVal, sidebarWidthVal, categorizationEnabled ?? null, markReadBehaviorVal, markReadDelayVal, aiActionsJson, rightSidebarWidth, rightSidebarHidden, gtdCollapsedSectionsJson, gtdPetSlug, autoLockMinutesVal, - showMobileAvatars ?? null, gravatarAvatars ?? null, folderSyncIntervalVal]); + showMobileAvatars ?? null, gravatarAvatars ?? null, folderSyncIntervalVal, conversationModeVal]); if (syncInterval != null) { const ms = parseInt(syncInterval) * 1000; diff --git a/backend/src/utils/conversationMode.js b/backend/src/utils/conversationMode.js new file mode 100644 index 00000000..79054acd --- /dev/null +++ b/backend/src/utils/conversationMode.js @@ -0,0 +1,5 @@ +const MODES = new Set(['off', 'list', 'pane']); + +export function sanitizeConversationMode(value) { + return MODES.has(value) ? value : null; +} diff --git a/backend/src/utils/conversationMode.test.js b/backend/src/utils/conversationMode.test.js new file mode 100644 index 00000000..c92ae305 --- /dev/null +++ b/backend/src/utils/conversationMode.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeConversationMode } from './conversationMode.js'; + +describe('sanitizeConversationMode', () => { + it.each(['off', 'list', 'pane'])('accepts %s', mode => { + expect(sanitizeConversationMode(mode)).toBe(mode); + }); + + it('rejects other values', () => { + expect(sanitizeConversationMode('threaded')).toBeNull(); + }); +}); diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index d09a47c1..c48f1978 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -6,6 +6,7 @@ import { applyLayout, normalizeLayout } from '../layouts.js'; import { DEFAULT_AI_ACTIONS } from '../aiActions.js'; import { removeGtdThreadFromSections, setGtdThreadReadInSections } from '../utils/gtd.js'; import { clampRightSidebarWidth } from '../utils/rightSidebar.js'; +import { resolveConversationMode } from '../utils/conversationMode.js'; import i18n from '../i18n.js'; // Accumulate rapid preference changes and flush at most once per second. @@ -389,12 +390,18 @@ export const useStore = create((set, get) => ({ schedulePrefSave({ language: lng }); }, - // Threaded view - threadedView: localStorage.getItem('mailflow_threaded_view') === 'true', - setThreadedView: (val) => { - localStorage.setItem('mailflow_threaded_view', String(val)); - set({ threadedView: val, expandedThreadId: null, threadMessages: {} }); - schedulePrefSave({ threadedView: val }); + // Conversation display + conversationMode: resolveConversationMode({ + conversationMode: localStorage.getItem('mailflow_conversation_mode'), + threadedView: localStorage.getItem('mailflow_threaded_view') == null + ? undefined + : localStorage.getItem('mailflow_threaded_view') === 'true', + }), + setConversationMode: (mode) => { + const resolvedMode = resolveConversationMode({ conversationMode: mode }); + localStorage.setItem('mailflow_conversation_mode', resolvedMode); + set({ conversationMode: resolvedMode, expandedThreadId: null, threadMessages: {} }); + schedulePrefSave({ conversationMode: resolvedMode }); }, // Compose format @@ -874,10 +881,9 @@ export const useStore = create((set, get) => ({ set({ language: prefs.language }); i18n.changeLanguage(prefs.language); } - if (typeof prefs.threadedView === 'boolean') { - localStorage.setItem('mailflow_threaded_view', String(prefs.threadedView)); - set({ threadedView: prefs.threadedView }); - } + const conversationMode = resolveConversationMode(prefs); + localStorage.setItem('mailflow_conversation_mode', conversationMode); + set({ conversationMode, expandedThreadId: null, threadMessages: {} }); if (typeof prefs.plaintextEmail === 'boolean') { localStorage.setItem('mailflow_plaintext_email', String(prefs.plaintextEmail)); set({ plaintextEmail: prefs.plaintextEmail }); diff --git a/frontend/src/utils/conversationMode.js b/frontend/src/utils/conversationMode.js new file mode 100644 index 00000000..928f7c20 --- /dev/null +++ b/frontend/src/utils/conversationMode.js @@ -0,0 +1,12 @@ +export const CONVERSATION_MODES = Object.freeze(['off', 'list', 'pane']); + +export const isConversationMode = value => CONVERSATION_MODES.includes(value); + +export function resolveConversationMode(prefs = {}) { + if (isConversationMode(prefs.conversationMode)) return prefs.conversationMode; + if (typeof prefs.threadedView === 'boolean') return prefs.threadedView ? 'list' : 'off'; + return 'off'; +} + +export const groupsMessageList = mode => mode === 'list' || mode === 'pane'; +export const expandsThreadsInline = mode => mode === 'list'; diff --git a/frontend/src/utils/conversationMode.test.js b/frontend/src/utils/conversationMode.test.js new file mode 100644 index 00000000..2459c9c4 --- /dev/null +++ b/frontend/src/utils/conversationMode.test.js @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveConversationMode } from './conversationMode.js'; + +describe('resolveConversationMode', () => { + it('prefers a valid conversationMode', () => { + assert.equal(resolveConversationMode({ conversationMode: 'pane', threadedView: false }), 'pane'); + }); + + it('maps legacy threadedView values', () => { + assert.equal(resolveConversationMode({ threadedView: true }), 'list'); + assert.equal(resolveConversationMode({ threadedView: false }), 'off'); + }); + + it('falls back to off', () => { + assert.equal(resolveConversationMode({ conversationMode: 'invalid' }), 'off'); + }); +}); From 705acdee08ca174d6aeaf547f80e1562fe88ef95 Mon Sep 17 00:00:00 2001 From: unknown <1721261724@qq.com> Date: Mon, 27 Jul 2026 00:11:56 +0800 Subject: [PATCH 02/11] feat: expose three conversation modes --- frontend/src/components/AdminPanel.jsx | 15 ++++++++------- frontend/src/locales/de.json | 6 ++++-- frontend/src/locales/en.json | 6 ++++-- frontend/src/locales/es.json | 6 ++++-- frontend/src/locales/fr.json | 6 ++++-- frontend/src/locales/it.json | 6 ++++-- frontend/src/locales/ru.json | 6 ++++-- frontend/src/locales/zhCN.json | 6 ++++-- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 52904473..096b0a82 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -1419,7 +1419,7 @@ function SwipeActionIcon({ action, size = 17 }) { function LayoutsTab() { const { t } = useTranslation(); const isMobile = useMobile(); - const { layout, setLayout, pageSize, setPageSize, scrollMode, setScrollMode, swipeActions, setSwipeAction, syncInterval, setSyncInterval, folderSyncInterval, setFolderSyncInterval, threadedView, setThreadedView, plaintextEmail, setPlaintextEmail, hoverQuickActions, setHoverQuickActions, showMobileAvatars, setShowMobileAvatars, gravatarAvatars, setGravatarAvatars, replyDefault, setReplyDefault, markReadBehavior, setMarkReadBehavior, markReadDelay, setMarkReadDelay } = useStore(); + const { layout, setLayout, pageSize, setPageSize, scrollMode, setScrollMode, swipeActions, setSwipeAction, syncInterval, setSyncInterval, folderSyncInterval, setFolderSyncInterval, conversationMode, setConversationMode, plaintextEmail, setPlaintextEmail, hoverQuickActions, setHoverQuickActions, showMobileAvatars, setShowMobileAvatars, gravatarAvatars, setGravatarAvatars, replyDefault, setReplyDefault, markReadBehavior, setMarkReadBehavior, markReadDelay, setMarkReadDelay } = useStore(); // "Set MailFlow as your default email app": registerProtocolHandler is the // cross-browser path (works in Firefox and non-installed Chromium) and must be @@ -1797,18 +1797,19 @@ function LayoutsTab() {
{t('admin.messageList.threadingMode')}
-
+
{[ - { id: false, label: t('admin.messageList.threadingOff'), desc: t('admin.messageList.threadingOffDesc') }, - { id: true, label: t('admin.messageList.threadingOn'), desc: t('admin.messageList.threadingOnDesc') }, + { id: 'off', label: t('admin.messageList.threadingOff'), desc: t('admin.messageList.threadingOffDesc') }, + { id: 'list', label: t('admin.messageList.threadingList'), desc: t('admin.messageList.threadingListDesc') }, + { id: 'pane', label: t('admin.messageList.threadingPane'), desc: t('admin.messageList.threadingPaneDesc') }, ].map(({ id, label, desc }) => { - const active = threadedView === id; + const active = conversationMode === id; return ( + ))} +
+
+ )} + + {beforeContent} + + {loadingBody &&
{['62%', '88%', '75%', '50%', '82%', '68%', '90%', '58%'].map((width, index) =>
)}
} + + {!loadingBody && bodyError &&
{t('message.loadingError')}
{bodyError}
} + + {!loadingBody && !bodyError && body && !body.html && !body.text &&
{t('message.noContent')}
} + + {!loadingBody && !bodyError && body?.html && ( +
+ {banner} + {body.hasBlockedRemoteImages &&
{t('message.remoteImagesBlocked')}
{[ + { label: t('message.loadImages'), handler: loadImages }, + senderEmail && { label: t('message.allowSender', { email: senderEmail }), handler: () => allowRemoteImages('address', senderEmail) }, + senderDomain && { label: t('message.allowDomain', { domain: senderDomain }), handler: () => allowRemoteImages('domain', senderDomain) }, + ].filter(Boolean).map(action => )}
} +
+ {USE_DIV_RENDER ?
: