Merge pull request #327 from Coffey-Labs/single-message-view

Let conversation view off mean off
This commit is contained in:
jcoffey
2026-09-10 14:23:52 -07:00
committed by GitHub
14 changed files with 195 additions and 13 deletions
+66
View File
@@ -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([]);
});
});
+37
View File
@@ -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<T extends { id: Id }>(messages: T[], openMessageId: Id | null): T[] {
if (!openMessageId) return messages;
const single = messages.filter((m) => m.id === openMessageId);
return single.length ? single : messages;
}
+2
View File
@@ -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 ────────────────────────────────────────────────
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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": "明日の朝",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.": "Значения по умолчанию для видов календаря и новых событий.",
+2
View File
@@ -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.": "Значення за замовчуванням для виглядів календаря та нових подій.",
+2
View File
@@ -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": "明天上午",
+54 -8
View File
@@ -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 && (
<div className="mail-reading-pane">
{threadId ? (
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => 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} />
<ThreadView key={`${threadId}:${openMessageId ?? ""}`} threadId={threadId} messageId={openMessageId} mailboxId={mailboxId ?? null} onBack={() => 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} />
) : (
<div className="no-thread">
<img src={withBase("/img/logo.png")} alt="" />
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
<div>
{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")}
</div>
<div className="hint">
{settings.conversationMode
? tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })
: tNode("Select a message to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}
</div>
</div>
)}
</div>
+9 -2
View File
@@ -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}
+11 -3
View File
@@ -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.