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.