From d599e7404f70001a2f5b6ba9ddde6a372405472a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 10 Sep 2026 14:15:50 -0700 Subject: [PATCH] Let conversation view off mean off The setting reached only as far as the query. It set `collapseThreads`, so the list correctly showed individual messages -- and then everything downstream carried on working in threads. Opening one message highlighted every row of its thread and filled the reading pane with the whole conversation, which is the grouping the setting was turned off to avoid. The empty pane went on offering "62 conversations" either way. Three places had to learn about it, and the two rules behind them now live together in lib/openMessage.ts: - the row highlight matched on threadId, so siblings lit up - ThreadView rendered every message the thread held - the empty state named conversations regardless The thread id stays in the path and loading is unchanged; the opened message rides in `m`. Keeping it in the URL rather than in memory is what makes a reload or a shared link come back to the same message, and an id that names nothing in the thread falls back to the conversation -- which is what a link from somebody with the setting on looks like, and what a stale parameter looks like after switching back. Better a conversation than an empty pane. Nine catalogues gain "No message selected" and "Select a message to read it here"; "{n} messages" was already there, plural forms and all. --- web/src/lib/__tests__/openMessage.test.ts | 66 +++++++++++++++++++++++ web/src/lib/openMessage.ts | 37 +++++++++++++ web/src/locales/de.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/fr.ts | 2 + web/src/locales/ja.ts | 2 + web/src/locales/nl.ts | 2 + web/src/locales/pt-BR.ts | 2 + web/src/locales/ru.ts | 2 + web/src/locales/uk.ts | 2 + web/src/locales/zh-Hans.ts | 2 + web/src/views/mail/MailView.tsx | 62 ++++++++++++++++++--- web/src/views/mail/MessageList.tsx | 11 +++- web/src/views/mail/ThreadView.tsx | 14 +++-- 14 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/__tests__/openMessage.test.ts create mode 100644 web/src/lib/openMessage.ts diff --git a/web/src/lib/__tests__/openMessage.test.ts b/web/src/lib/__tests__/openMessage.test.ts new file mode 100644 index 0000000..aac0d89 --- /dev/null +++ b/web/src/lib/__tests__/openMessage.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { rowIsOpen, visibleMessages } from "../openMessage"; + +/* + * Reported from the inbox: with conversation view off, the list showed the two + * messages of a thread as separate rows -- correctly -- but clicking either one + * highlighted *both* and filled the reading pane with all five messages of the + * conversation. + * + * The setting reached only as far as `collapseThreads` on the query. These are + * the two rules that were missing downstream. + */ + +const msg = (id: string) => ({ id }); + +describe("which row is drawn as open", () => { + it("marks only the opened message, not its siblings", () => { + // The reported case: two rows, one thread, one of them opened. + expect(rowIsOpen("m1", "t1", "m1", "t1")).toBe(true); + expect(rowIsOpen("m2", "t1", "m1", "t1")).toBe(false); + }); + + it("still marks the whole thread when conversation view is on", () => { + // No message singled out: every row of the open thread is part of what the + // reading pane is showing, so every one of them is open. + expect(rowIsOpen("m1", "t1", null, "t1")).toBe(true); + expect(rowIsOpen("m2", "t1", null, "t1")).toBe(true); + expect(rowIsOpen("m3", "t2", null, "t1")).toBe(false); + }); + + it("marks nothing when nothing is open", () => { + expect(rowIsOpen("m1", "t1", null, null)).toBe(false); + }); + + it("does not mark a row whose thread is unknown", () => { + // A row whose email has not loaded yet has no thread id; `undefined` must + // not match a null openThreadId and light the row up. + expect(rowIsOpen("m1", undefined, null, null)).toBe(false); + }); +}); + +describe("which messages the reading pane shows", () => { + const thread = [msg("a"), msg("b"), msg("c")]; + + it("shows just the opened message", () => { + expect(visibleMessages(thread, "b")).toEqual([msg("b")]); + }); + + it("shows the whole thread when none is singled out", () => { + expect(visibleMessages(thread, null)).toEqual(thread); + }); + + it("falls back to the thread when the id names nothing in it", () => { + /* + * Two ways to arrive here: a link shared by somebody whose conversation + * view is on, and an `m` parameter left in the URL when the setting is + * switched back. A conversation is a better answer to both than an empty + * pane, which is what filtering to nothing would produce. + */ + expect(visibleMessages(thread, "zzz")).toEqual(thread); + }); + + it("leaves an empty thread empty rather than inventing a message", () => { + expect(visibleMessages([], "b")).toEqual([]); + }); +}); diff --git a/web/src/lib/openMessage.ts b/web/src/lib/openMessage.ts new file mode 100644 index 0000000..22ceec0 --- /dev/null +++ b/web/src/lib/openMessage.ts @@ -0,0 +1,37 @@ +/** + * What "open" means when conversation view is off. + * + * The setting used to reach only as far as the query -- it set `collapseThreads` + * and nothing else -- so the list showed individual messages while everything + * downstream still worked in threads. Opening one message highlighted every row + * in its thread and filled the reading pane with the whole conversation, which + * is exactly the grouping the setting was turned off to avoid. + * + * Both halves are the same question asked in two places, so they live together. + */ +import type { Id } from "@/jmap/types"; + +/** + * Whether a list row should be drawn as the open one. + * + * With a message singled out the row must match it exactly. Matching on the + * thread is what lit up every sibling. + */ +export function rowIsOpen(rowId: Id, rowThreadId: Id | undefined, openMessageId: Id | null, openThreadId: Id | null): boolean { + if (openMessageId) return rowId === openMessageId; + return Boolean(openThreadId) && rowThreadId === openThreadId; +} + +/** + * The messages the reading pane should render. + * + * Falls back to the whole thread when the id names nothing in it. That is what + * a link from somebody with conversation view *on* looks like, and what a + * lingering `m` parameter looks like after the setting is switched back -- a + * conversation is a better answer to both than an empty pane. + */ +export function visibleMessages(messages: T[], openMessageId: Id | null): T[] { + if (!openMessageId) return messages; + const single = messages.filter((m) => m.id === openMessageId); + return single.length ? single : messages; +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 2327417..61ca2c2 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -756,6 +756,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.", "Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel", + "Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.", "Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.", @@ -834,6 +835,7 @@ export const catalog: Catalog = { "Nothing": "Nichts", "No conversation selected": "Keine Konversation ausgewählt", + "No message selected": "Keine Nachricht ausgewählt", "Drop here for the top level": "Hierher ziehen für die oberste Ebene", // ── Remaining prose ──────────────────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 8989ca6..df1f181 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -811,6 +811,7 @@ export const catalog: Catalog = { "Rename folder": "Cambiar el nombre de la carpeta", "Search: {query}": "Búsqueda: {query}", "No conversation selected": "Ninguna conversación seleccionada", + "No message selected": "Ningún mensaje seleccionado", "Drop here for the top level": "Suelte aquí para el nivel superior", // ── Longer prose ─────────────────────────────────────────────────── @@ -819,6 +820,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Abra la vista de Correo para ver todos los atajos.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Los atajos al estilo de Gmail están siempre activos. Pulse {key} en cualquier momento para ver esta lista.", "Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos", + "Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.", "Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 740a5c6..48d523c 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -816,6 +816,7 @@ export const catalog: Catalog = { "Rename folder": "Renommer le dossier", "Search: {query}": "Recherche : {query}", "No conversation selected": "Aucune conversation sélectionnée", + "No message selected": "Aucun message sélectionné", "Drop here for the top level": "Déposer ici pour le niveau supérieur", // ── Longer prose ─────────────────────────────────────────────────── @@ -824,6 +825,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Ouvrez la vue E-mail pour voir tous les raccourcis.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Les raccourcis façon Gmail sont toujours actifs. Appuyez sur {key} n'importe où pour afficher cette liste.", "Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis", + "Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.", "Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index fbb761f..6aed729 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -747,6 +747,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 形式のショートカットは常に有効です。どこでも {key} を押すとこの一覧を表示します。", "Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧", + "Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。", @@ -843,6 +844,7 @@ export const catalog: Catalog = { "Not spam": "迷惑メールではない", "Nothing": "何もしない", "No conversation selected": "スレッドが選択されていません", + "No message selected": "メールが選択されていません", "Drop here for the top level": "ここにドロップすると最上位へ移動します", "Later today": "今日のうちに", "Tomorrow morning": "明日の朝", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index a3a63e2..ea75229 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -807,6 +807,7 @@ export const catalog: Catalog = { "Rename folder": "Map hernoemen", "Search: {query}": "Zoeken: {query}", "No conversation selected": "Geen gesprek geselecteerd", + "No message selected": "Geen bericht geselecteerd", "Drop here for the top level": "Hier neerzetten voor het hoogste niveau", // ── Longer prose ─────────────────────────────────────────────────── @@ -815,6 +816,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.", "Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen", + "Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.", "Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 533daea..4e0bb65 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -814,6 +814,7 @@ export const catalog: Catalog = { "Rename folder": "Renomear a pasta", "Search: {query}": "Pesquisa: {query}", "No conversation selected": "Nenhuma conversa selecionada", + "No message selected": "Nenhuma mensagem selecionada", "Drop here for the top level": "Solte aqui para o nível superior", // ── Longer prose ─────────────────────────────────────────────────── @@ -822,6 +823,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Abra a visualização de E-mail para ver todos os atalhos.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Os atalhos no estilo do Gmail estão sempre ativos. Pressione {key} em qualquer lugar para ver esta lista.", "Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos", + "Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.", "Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 4f3bec9..ae619b4 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -813,6 +813,7 @@ export const catalog: Catalog = { "Rename folder": "Переименовать папку", "Search: {query}": "Поиск: {query}", "No conversation selected": "Цепочка не выбрана", + "No message selected": "Письмо не выбрано", "Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень", // ── Longer prose ─────────────────────────────────────────────────── @@ -821,6 +822,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сочетания клавиш в стиле Gmail всегда включены. Нажмите {key} в любом месте, чтобы увидеть этот список.", "Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш", + "Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.", "Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 509e6cd..669a082 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -807,6 +807,7 @@ export const catalog: Catalog = { "Rename folder": "Перейменувати теку", "Search: {query}": "Пошук: {query}", "No conversation selected": "Листування не вибрано", + "No message selected": "Лист не вибрано", "Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень", // ── Longer prose ─────────────────────────────────────────────────── @@ -815,6 +816,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сполучення клавіш у стилі Gmail завжди увімкнено. Натисніть {key} будь-де, щоб побачити цей список.", "Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш", + "Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.", "Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index ac294ae..19569b9 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -746,6 +746,7 @@ export const catalog: Catalog = { "Open the Mail view to see all shortcuts.": "打开邮件视图以查看全部快捷键。", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 风格的快捷键始终启用。在任意位置按 {key} 即可查看此列表。", "Select a conversation to read it here · Press {key} for shortcuts": "选择一个会话即可在此阅读 · 按 {key} 查看快捷键", + "Select a message to read it here · Press {key} for shortcuts": "选择一封邮件即可在此阅读 · 按 {key} 查看快捷键", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "提示:在会话上按 {key} 可添加标签。使用 {operator} 搜索。", "A fast, friendly, open-source webmail for {server}, built on JMAP.": "一款面向 {server} 的快速、友好的开源网页邮箱,基于 JMAP 构建。", @@ -842,6 +843,7 @@ export const catalog: Catalog = { "Not spam": "不是垃圾邮件", "Nothing": "不执行任何操作", "No conversation selected": "未选择会话", + "No message selected": "未选择邮件", "Drop here for the top level": "拖放到此处可移至顶层", "Later today": "今天晚些时候", "Tomorrow morning": "明天上午", diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 3e4e896..69d71b7 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -107,15 +107,40 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile(); }, [mailboxId, scheduledId, mailboxesLoaded, reconcile]); + /* + * With conversation view off, a row is a message rather than a thread, and + * opening one must show that message and highlight that row -- not its whole + * thread and every sibling row in the list. + * + * The thread id stays in the path, so loading is unchanged; the message rides + * in `m`. Putting it in the URL rather than in memory is what makes a reload + * or a shared link land back on the same message, and dropping the parameter + * degrades to the conversation, which is the right thing for a link sent to + * somebody whose setting differs. + */ const openThread = useCallback( - (tid: Id | null) => { + (tid: Id | null, messageId?: Id | null) => { const base = search ? `/search` : `/mail/${mailboxId}`; - const qs = search ? `?q=${encodeURIComponent(q)}` : ""; + const params = new URLSearchParams(); + if (search) params.set("q", q); + if (tid && messageId) params.set("m", messageId); + const qs = params.size ? `?${params}` : ""; navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`); }, [navigate, search, mailboxId, q], ); + /** + * The message the URL singles out, if any. Only meaningful with conversation + * view off; ThreadView decides what to do when the id names nothing in the + * thread, since it is the part that knows what the thread holds. + */ + const openMessageId = useMemo(() => { + if (settings.conversationMode) return null; + const m = new URLSearchParams(searchStr).get("m"); + return m || null; + }, [settings.conversationMode, searchStr]); + // Row ids in list + helpers for keyboard nav const ids = list?.ids ?? []; const emails = useMail((s) => s.emails); @@ -129,9 +154,17 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; const i = ids.indexOf(focusId); if (i >= 0) return i; } + // A cold load has no focus yet. With conversation view off the URL names the + // row exactly; matching on the thread instead would land on whichever of its + // messages sorts first, so j/k and the scroll-into-view would start from the + // wrong row on any thread with more than one message in the folder. + if (openMessageId) { + const i = ids.indexOf(openMessageId); + if (i >= 0) return i; + } if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId); return -1; - }, [ids, focusId, threadId, rowThreadId]); + }, [ids, focusId, openMessageId, threadId, rowThreadId]); /** Email ids affected by an action on rows (selection or focused/open row). */ const targetIds = useCallback( @@ -339,9 +372,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; void openDraft(e); return; } - openThread(e.threadId); + openThread(e.threadId, settings.conversationMode ? null : rowId); }, - [emails, mailboxId, mailboxes, openThread, openDraft], + [emails, mailboxId, mailboxes, openThread, openDraft, settings.conversationMode], ); const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail"); @@ -373,6 +406,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; title={title} list={list} openThreadId={threadId ?? null} + openMessageId={openMessageId} focusId={focusId} setFocusId={setFocusId} onOpen={onOpenRow} @@ -387,12 +421,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; {showReading && (
{threadId ? ( - openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} /> + openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t, settings.conversationMode ? null : next!); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} /> ) : (
-
{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}
-
{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: ? })}
+
+ {list?.total + ? settings.conversationMode + ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) + : plural(list.total, { one: "{n} message", other: "{n} messages" }) + : settings.conversationMode + ? translate("No conversation selected") + : translate("No message selected")} +
+
+ {settings.conversationMode + ? tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: ? }) + : tNode("Select a message to read it here · Press {key} for shortcuts", { key: ? })} +
)}
diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 5c6ecfb..7d5c70f 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -9,6 +9,7 @@ import { formatListDate } from "@/lib/format"; import { mailboxDisplayName } from "@/lib/mailboxName"; import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; +import { rowIsOpen } from "@/lib/openMessage"; import { displayName, shortName } from "@/lib/address"; import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc"; import { rowClick } from "@/lib/listSelection"; @@ -53,6 +54,12 @@ interface Props { title: string; list: ListState | null; openThreadId: Id | null; + /** + * With conversation view off, the one message the reading pane is showing. + * The row highlight follows this instead of the thread, or every message in + * a thread lights up when one of them is opened. + */ + openMessageId: Id | null; focusId: Id | null; setFocusId: (id: Id | null) => void; onOpen: (rowId: Id) => void; @@ -61,7 +68,7 @@ interface Props { isSearch: boolean; } -export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) { +export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) { const [, navigate] = useLocation(); const emails = useMail((s) => s.emails); const threads = useMail((s) => s.threads); @@ -485,7 +492,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on height={vi.size} selected={Boolean(selected[id])} focused={focusId === id} - open={openThreadId === e.threadId} + open={rowIsOpen(id, e.threadId, openMessageId, openThreadId)} twoLine={twoLine} showAvatar={settings.showAvatars} showPreview={settings.showPreview} diff --git a/web/src/views/mail/ThreadView.tsx b/web/src/views/mail/ThreadView.tsx index 5fb4574..6a2c972 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react"; import { useMail } from "@/store/mail"; +import { visibleMessages } from "@/lib/openMessage"; import { useSettings } from "@/store/settings"; import { useCompose } from "@/store/compose"; import type { Email, Id } from "@/jmap/types"; @@ -25,9 +26,15 @@ interface Props { onNavigate: (delta: number) => void; hasPrev: boolean; hasNext: boolean; + /** + * With conversation view off, the single message to show. The thread is still + * what loads -- one request, and the reply/forward paths keep the context + * they need -- but only this message is rendered. + */ + messageId?: Id | null; } -export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) { +export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext, messageId = null }: Props) { const loadThread = useMail((s) => s.loadThread); const thread = useMail((s) => s.threads[threadId]); const emails = useMail((s) => s.emails); @@ -82,8 +89,9 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h if (junk && e.mailboxIds[junk]) return false; return true; }); - return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); - }, [thread, emails, fullIds, mailboxId]); + const shown = filtered.length ? filtered : all; + return visibleMessages(shown, messageId).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); + }, [thread, emails, fullIds, mailboxId, messageId]); /* * Which messages were unread when this conversation was opened.