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.
This commit is contained in:
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.",
|
"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.",
|
"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 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}.",
|
"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.",
|
"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.",
|
"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",
|
"Nothing": "Nichts",
|
||||||
|
|
||||||
"No conversation selected": "Keine Konversation ausgewählt",
|
"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",
|
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
|
||||||
|
|
||||||
// ── Remaining prose ────────────────────────────────────────────────
|
// ── Remaining prose ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -811,6 +811,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Cambiar el nombre de la carpeta",
|
"Rename folder": "Cambiar el nombre de la carpeta",
|
||||||
"Search: {query}": "Búsqueda: {query}",
|
"Search: {query}": "Búsqueda: {query}",
|
||||||
"No conversation selected": "Ninguna conversación seleccionada",
|
"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",
|
"Drop here for the top level": "Suelte aquí para el nivel superior",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── 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.",
|
"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.",
|
"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 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}.",
|
"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.",
|
"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.",
|
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
|
||||||
|
|||||||
@@ -816,6 +816,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Renommer le dossier",
|
"Rename folder": "Renommer le dossier",
|
||||||
"Search: {query}": "Recherche : {query}",
|
"Search: {query}": "Recherche : {query}",
|
||||||
"No conversation selected": "Aucune conversation sélectionnée",
|
"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",
|
"Drop here for the top level": "Déposer ici pour le niveau supérieur",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── 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.",
|
"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.",
|
"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 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}.",
|
"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.",
|
"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.",
|
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。",
|
"Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 形式のショートカットは常に有効です。どこでも {key} を押すとこの一覧を表示します。",
|
"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 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} が使えます。",
|
"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 で動作します。",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
|
||||||
|
|
||||||
@@ -843,6 +844,7 @@ export const catalog: Catalog = {
|
|||||||
"Not spam": "迷惑メールではない",
|
"Not spam": "迷惑メールではない",
|
||||||
"Nothing": "何もしない",
|
"Nothing": "何もしない",
|
||||||
"No conversation selected": "スレッドが選択されていません",
|
"No conversation selected": "スレッドが選択されていません",
|
||||||
|
"No message selected": "メールが選択されていません",
|
||||||
"Drop here for the top level": "ここにドロップすると最上位へ移動します",
|
"Drop here for the top level": "ここにドロップすると最上位へ移動します",
|
||||||
"Later today": "今日のうちに",
|
"Later today": "今日のうちに",
|
||||||
"Tomorrow morning": "明日の朝",
|
"Tomorrow morning": "明日の朝",
|
||||||
|
|||||||
@@ -807,6 +807,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Map hernoemen",
|
"Rename folder": "Map hernoemen",
|
||||||
"Search: {query}": "Zoeken: {query}",
|
"Search: {query}": "Zoeken: {query}",
|
||||||
"No conversation selected": "Geen gesprek geselecteerd",
|
"No conversation selected": "Geen gesprek geselecteerd",
|
||||||
|
"No message selected": "Geen bericht geselecteerd",
|
||||||
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
|
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── 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.",
|
"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.",
|
"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 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}.",
|
"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.",
|
"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.",
|
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Renomear a pasta",
|
"Rename folder": "Renomear a pasta",
|
||||||
"Search: {query}": "Pesquisa: {query}",
|
"Search: {query}": "Pesquisa: {query}",
|
||||||
"No conversation selected": "Nenhuma conversa selecionada",
|
"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",
|
"Drop here for the top level": "Solte aqui para o nível superior",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── 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.",
|
"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.",
|
"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 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}.",
|
"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.",
|
"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.",
|
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
|
||||||
|
|||||||
@@ -813,6 +813,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Переименовать папку",
|
"Rename folder": "Переименовать папку",
|
||||||
"Search: {query}": "Поиск: {query}",
|
"Search: {query}": "Поиск: {query}",
|
||||||
"No conversation selected": "Цепочка не выбрана",
|
"No conversation selected": "Цепочка не выбрана",
|
||||||
|
"No message selected": "Письмо не выбрано",
|
||||||
"Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень",
|
"Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -821,6 +822,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.",
|
"Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сочетания клавиш в стиле Gmail всегда включены. Нажмите {key} в любом месте, чтобы увидеть этот список.",
|
"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 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}.",
|
"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.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
|
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
|
||||||
|
|||||||
@@ -807,6 +807,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Перейменувати теку",
|
"Rename folder": "Перейменувати теку",
|
||||||
"Search: {query}": "Пошук: {query}",
|
"Search: {query}": "Пошук: {query}",
|
||||||
"No conversation selected": "Листування не вибрано",
|
"No conversation selected": "Листування не вибрано",
|
||||||
|
"No message selected": "Лист не вибрано",
|
||||||
"Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень",
|
"Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -815,6 +816,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.",
|
"Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сполучення клавіш у стилі Gmail завжди увімкнено. Натисніть {key} будь-де, щоб побачити цей список.",
|
"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 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}.",
|
"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.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
|
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "打开邮件视图以查看全部快捷键。",
|
"Open the Mail view to see all shortcuts.": "打开邮件视图以查看全部快捷键。",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 风格的快捷键始终启用。在任意位置按 {key} 即可查看此列表。",
|
"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 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} 搜索。",
|
"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 构建。",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "一款面向 {server} 的快速、友好的开源网页邮箱,基于 JMAP 构建。",
|
||||||
|
|
||||||
@@ -842,6 +843,7 @@ export const catalog: Catalog = {
|
|||||||
"Not spam": "不是垃圾邮件",
|
"Not spam": "不是垃圾邮件",
|
||||||
"Nothing": "不执行任何操作",
|
"Nothing": "不执行任何操作",
|
||||||
"No conversation selected": "未选择会话",
|
"No conversation selected": "未选择会话",
|
||||||
|
"No message selected": "未选择邮件",
|
||||||
"Drop here for the top level": "拖放到此处可移至顶层",
|
"Drop here for the top level": "拖放到此处可移至顶层",
|
||||||
"Later today": "今天晚些时候",
|
"Later today": "今天晚些时候",
|
||||||
"Tomorrow morning": "明天上午",
|
"Tomorrow morning": "明天上午",
|
||||||
|
|||||||
@@ -107,15 +107,40 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile();
|
if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile();
|
||||||
}, [mailboxId, scheduledId, mailboxesLoaded, 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(
|
const openThread = useCallback(
|
||||||
(tid: Id | null) => {
|
(tid: Id | null, messageId?: Id | null) => {
|
||||||
const base = search ? `/search` : `/mail/${mailboxId}`;
|
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(tid ? `${base}/${tid}${qs}` : `${base}${qs}`);
|
||||||
},
|
},
|
||||||
[navigate, search, mailboxId, q],
|
[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
|
// Row ids in list + helpers for keyboard nav
|
||||||
const ids = list?.ids ?? [];
|
const ids = list?.ids ?? [];
|
||||||
const emails = useMail((s) => s.emails);
|
const emails = useMail((s) => s.emails);
|
||||||
@@ -129,9 +154,17 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
const i = ids.indexOf(focusId);
|
const i = ids.indexOf(focusId);
|
||||||
if (i >= 0) return i;
|
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);
|
if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId);
|
||||||
return -1;
|
return -1;
|
||||||
}, [ids, focusId, threadId, rowThreadId]);
|
}, [ids, focusId, openMessageId, threadId, rowThreadId]);
|
||||||
|
|
||||||
/** Email ids affected by an action on rows (selection or focused/open row). */
|
/** Email ids affected by an action on rows (selection or focused/open row). */
|
||||||
const targetIds = useCallback(
|
const targetIds = useCallback(
|
||||||
@@ -339,9 +372,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
void openDraft(e);
|
void openDraft(e);
|
||||||
return;
|
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");
|
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}
|
title={title}
|
||||||
list={list}
|
list={list}
|
||||||
openThreadId={threadId ?? null}
|
openThreadId={threadId ?? null}
|
||||||
|
openMessageId={openMessageId}
|
||||||
focusId={focusId}
|
focusId={focusId}
|
||||||
setFocusId={setFocusId}
|
setFocusId={setFocusId}
|
||||||
onOpen={onOpenRow}
|
onOpen={onOpenRow}
|
||||||
@@ -387,12 +421,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
{showReading && (
|
{showReading && (
|
||||||
<div className="mail-reading-pane">
|
<div className="mail-reading-pane">
|
||||||
{threadId ? (
|
{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">
|
<div className="no-thread">
|
||||||
<img src={withBase("/img/logo.png")} alt="" />
|
<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>
|
||||||
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatListDate } from "@/lib/format";
|
|||||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
|
import { rowIsOpen } from "@/lib/openMessage";
|
||||||
import { displayName, shortName } from "@/lib/address";
|
import { displayName, shortName } from "@/lib/address";
|
||||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||||
import { rowClick } from "@/lib/listSelection";
|
import { rowClick } from "@/lib/listSelection";
|
||||||
@@ -53,6 +54,12 @@ interface Props {
|
|||||||
title: string;
|
title: string;
|
||||||
list: ListState | null;
|
list: ListState | null;
|
||||||
openThreadId: Id | 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;
|
focusId: Id | null;
|
||||||
setFocusId: (id: Id | null) => void;
|
setFocusId: (id: Id | null) => void;
|
||||||
onOpen: (rowId: Id) => void;
|
onOpen: (rowId: Id) => void;
|
||||||
@@ -61,7 +68,7 @@ interface Props {
|
|||||||
isSearch: boolean;
|
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 [, navigate] = useLocation();
|
||||||
const emails = useMail((s) => s.emails);
|
const emails = useMail((s) => s.emails);
|
||||||
const threads = useMail((s) => s.threads);
|
const threads = useMail((s) => s.threads);
|
||||||
@@ -485,7 +492,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
height={vi.size}
|
height={vi.size}
|
||||||
selected={Boolean(selected[id])}
|
selected={Boolean(selected[id])}
|
||||||
focused={focusId === id}
|
focused={focusId === id}
|
||||||
open={openThreadId === e.threadId}
|
open={rowIsOpen(id, e.threadId, openMessageId, openThreadId)}
|
||||||
twoLine={twoLine}
|
twoLine={twoLine}
|
||||||
showAvatar={settings.showAvatars}
|
showAvatar={settings.showAvatars}
|
||||||
showPreview={settings.showPreview}
|
showPreview={settings.showPreview}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { 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 { useMail } from "@/store/mail";
|
||||||
|
import { visibleMessages } from "@/lib/openMessage";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import type { Email, Id } from "@/jmap/types";
|
import type { Email, Id } from "@/jmap/types";
|
||||||
@@ -25,9 +26,15 @@ interface Props {
|
|||||||
onNavigate: (delta: number) => void;
|
onNavigate: (delta: number) => void;
|
||||||
hasPrev: boolean;
|
hasPrev: boolean;
|
||||||
hasNext: 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 loadThread = useMail((s) => s.loadThread);
|
||||||
const thread = useMail((s) => s.threads[threadId]);
|
const thread = useMail((s) => s.threads[threadId]);
|
||||||
const emails = useMail((s) => s.emails);
|
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;
|
if (junk && e.mailboxIds[junk]) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
const shown = filtered.length ? filtered : all;
|
||||||
}, [thread, emails, fullIds, mailboxId]);
|
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.
|
* Which messages were unread when this conversation was opened.
|
||||||
|
|||||||
Reference in New Issue
Block a user