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 (