summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 19:00:59 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 19:00:59 +0200
commitae74237ca11640ceb68887b2c6d09ecf1befb342 (patch)
tree6ae37c5ed27eeb27c55a17febe16d03fa3553503
parent46acb489691e5298181ce52bfe847889ee06fe68 (diff)
downloadqtmaildir-ae74237ca11640ceb68887b2c6d09ecf1befb342.tar.gz
qtmaildir-ae74237ca11640ceb68887b2c6d09ecf1befb342.zip
feat: show the dashboard when a conversation is selected
A thread row has no message to render, so the pane shows the conversation instead. A thread of one message still opens its message on one click, and the automatic mark-read is not armed for a row that displays nothing.
-rw-r--r--src/mainwindow.cpp179
-rw-r--r--src/mainwindow.h44
-rw-r--r--src/messageview.cpp86
-rw-r--r--src/messageview.h37
-rw-r--r--src/threaddashboard.cpp3
-rw-r--r--tests/test_mainwindow.cpp330
6 files changed, 603 insertions, 76 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 4cce042..321c608 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -69,6 +69,7 @@
#include "pendingchangesdialog.h"
#include "savequerydialog.h"
#include "tagrulesdialog.h"
+#include "threaddashboard.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
#include "version.h"
@@ -960,6 +961,44 @@ void MainWindow::buildUi()
connect(m_messageView, &MessageView::searchRequested,
this, &MainWindow::runSearchFromPane);
+ // The conversation dashboard (item 177). Its colours are the application's
+ // own, so the strip it holds reads the same table every other chip does.
+ ThreadDashboard *dashboard = m_messageView->dashboard();
+ dashboard->setTagColors(&m_tagColors);
+
+ // An unread entry is a way INTO the conversation: expand the thread and
+ // select that message, which is the ordinary gesture rather than a second
+ // way to open a message.
+ connect(dashboard, &ThreadDashboard::messageActivated,
+ this, &MainWindow::selectMessageInCurrentThread);
+
+ // The "+N more" link. The full list already lives in the left pane, so
+ // this expands the thread there and leaves the selection alone.
+ connect(dashboard, &ThreadDashboard::expandRequested, this, [this]() {
+ const QModelIndex current = m_threadView->currentIndex();
+ if (current.isValid() && !m_model->isMessageRow(current))
+ m_threadView->expand(current);
+ });
+
+ // The three buttons TRIGGER the existing actions rather than reimplement
+ // them. Those actions already resolve their scope from the selection, and
+ // a conversation row resolves to the whole conversation, which is exactly
+ // what a button on this pane means. A second path to the same write is the
+ // mistake the deleted "Whole thread" submenu made.
+ //
+ // Looked up at emit time: registerActions() runs after buildUi(), so the
+ // map is empty here.
+ const auto triggerAction = [this](const QString &name) {
+ if (QAction *action = m_actions.value(name))
+ action->trigger();
+ };
+ connect(dashboard, &ThreadDashboard::markAllReadRequested, this,
+ [triggerAction]() { triggerAction(QStringLiteral("mark_all_read")); });
+ connect(dashboard, &ThreadDashboard::archiveRequested, this,
+ [triggerAction]() { triggerAction(QStringLiteral("archive")); });
+ connect(dashboard, &ThreadDashboard::deleteRequested, this,
+ [triggerAction]() { triggerAction(QStringLiteral("delete")); });
+
m_splitter = new QSplitter(Qt::Horizontal, central);
m_splitter->addWidget(m_threadView);
m_splitter->addWidget(m_messageView);
@@ -2558,6 +2597,8 @@ void MainWindow::wireWorker()
this, &MainWindow::onThreadTreeLoaded);
connect(m_worker, &NotmuchWorker::messageLoaded,
this, &MainWindow::onMessageLoaded);
+ connect(m_worker, &NotmuchWorker::threadDigestLoaded,
+ this, &MainWindow::onThreadDigestLoaded);
connect(m_worker, &NotmuchWorker::errorOccurred,
this, &MainWindow::onWorkerError);
connect(m_worker, &NotmuchWorker::allTagsReady,
@@ -4063,6 +4104,52 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
+ // A CONVERSATION row stands for the whole thread, so there is no single
+ // message to render and the pane shows the conversation instead (item
+ // 177). A thread of one message is not a conversation and falls through to
+ // the message path below, which is the case the whole split protects: it
+ // must still open on one click.
+ if (m_model->isConversationRow(current)) {
+ // No mark-read, deliberately: the row displays nothing, so there is no
+ // message the user can be said to have read. Any timer armed for the
+ // row they came from is still cancelled.
+ m_markReadTimer->stop();
+ m_markReadMessageId.clear();
+
+ m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
+
+ m_dashboardThreadId = thread.threadId;
+
+ // The heading before the round trip, not after it: the subject, the
+ // account and the tags are already on the summary, and the digest
+ // carries none of them. Waiting would show an empty heading for as
+ // long as the worker takes.
+ m_messageView->setDashboardThread(
+ thread.subject,
+ m_model->data(current, ThreadListModel::AccountLabelRole).toString(),
+ thread.tags);
+
+ // Empty for now, so the pane switches at once rather than staying on
+ // the previous message until the digest lands.
+ ThreadDigest pending;
+ pending.threadId = thread.threadId;
+ pending.totalCount = thread.totalCount;
+ m_messageView->showDashboard(pending);
+
+ // Its OWN generation. m_generation is the query's, and bumping that
+ // discards any thread load in flight and blanks the pane, which is the
+ // opposite of what selecting a row should do.
+ ++m_digestGeneration;
+ QMetaObject::invokeMethod(m_worker, "loadThreadDigest",
+ Qt::QueuedConnection,
+ Q_ARG(QString, thread.threadId),
+ Q_ARG(quint64, m_digestGeneration));
+ return;
+ }
+
+ m_dashboardThreadId.clear();
+
// The message the card displays, not the thread. The summary's `unread` is
// a union over the conversation, so this can arm for a thread whose first
// message is already read; the write is scoped to that message either way,
@@ -4100,6 +4187,32 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
Q_ARG(quint64, m_generation));
}
+void MainWindow::onThreadDigestLoaded(const ThreadDigest &digest,
+ quint64 generation)
+{
+ // Three guards, and each one covers a different way the answer can outlive
+ // the selection that asked for it. loadThreadDigest crosses on a queued
+ // connection, so all of them are reachable by moving the selection while
+ // one is in flight.
+ //
+ // Superseded by a later request: the user moved to another conversation.
+ if (generation != m_digestGeneration)
+ return;
+
+ // The pane is no longer showing a dashboard at all: it was blanked, or a
+ // message row was selected. Every route that renders a message switches
+ // the stack back, so this is the question rather than a flag of our own.
+ if (!m_messageView->showingDashboard())
+ return;
+
+ // And it is showing a DIFFERENT conversation. The generation guard alone
+ // would let this through if a request were ever made without bumping it.
+ if (digest.threadId != m_dashboardThreadId)
+ return;
+
+ m_messageView->showDashboard(digest);
+}
+
void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
quint64 generation)
{
@@ -4217,6 +4330,9 @@ void MainWindow::onThreadTreeLoaded(const QVector<MessageNode> &nodes,
// A stale-thread recovery waits for exactly this: the message it wants to
// select does not exist as a row until the replies land.
applyPendingRecovery();
+
+ // And so does a dashboard entry, for the same reason.
+ applyPendingDashboardSelection();
}
void MainWindow::renderMessages(const QVector<MessageRef> &messages)
@@ -4761,6 +4877,69 @@ void MainWindow::onRowDoubleClicked(const QModelIndex &index)
recoverStaleThread(threadId, messageId);
}
+void MainWindow::selectMessageInCurrentThread(const QString &messageId)
+{
+ if (messageId.isEmpty())
+ return;
+
+ const QModelIndex current = m_threadView->currentIndex();
+ if (!current.isValid() || m_model->isMessageRow(current))
+ return;
+
+ // Expanded FIRST, and unconditionally: this is also what asks the worker
+ // for the replies, so a collapsed thread has no row to select yet.
+ m_threadView->expand(current);
+
+ m_dashboardSelectThreadId = m_model->threadFor(current).threadId;
+ m_dashboardSelectMessageId = messageId;
+ applyPendingDashboardSelection();
+}
+
+void MainWindow::applyPendingDashboardSelection()
+{
+ if (m_dashboardSelectMessageId.isEmpty())
+ return;
+
+ for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
+ const QModelIndex thread = m_model->index(row, 0, QModelIndex());
+ if (m_model->threadAt(row).threadId != m_dashboardSelectThreadId)
+ continue;
+
+ // The thread's first message is the ROOT row, not a child:
+ // setThreadMessages drops depth 0 because the root stands for it, so
+ // looking for it among the children finds nothing.
+ if (m_model->data(thread, ThreadListModel::MessageIdRole).toString()
+ == m_dashboardSelectMessageId) {
+ selectRowAt(thread);
+ m_dashboardSelectMessageId.clear();
+ m_dashboardSelectThreadId.clear();
+ return;
+ }
+
+ for (int child = 0; child < m_model->rowCount(thread); ++child) {
+ const QModelIndex reply = m_model->index(child, 0, thread);
+ if (m_model->messageAt(reply).messageId
+ != m_dashboardSelectMessageId)
+ continue;
+ selectRowAt(reply);
+ m_dashboardSelectMessageId.clear();
+ m_dashboardSelectThreadId.clear();
+ return;
+ }
+
+ // The replies have not arrived. The target stays remembered and
+ // onThreadTreeLoaded() runs this again when they do; the selection is
+ // deliberately left where it is until then, so the dashboard the user
+ // clicked from stays on screen rather than flashing to something else.
+ return;
+ }
+
+ // The thread is not in the list at all, which the query having moved on
+ // would produce. Nothing to select, and nothing to keep waiting for.
+ m_dashboardSelectMessageId.clear();
+ m_dashboardSelectThreadId.clear();
+}
+
void MainWindow::applyPendingRecovery()
{
if (m_recoverThreadId.isEmpty())
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 11f35e7..16bf8c4 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -41,6 +41,7 @@
#include "tagrules.h"
#include "syncmonitor.h"
#include "tagcolors.h"
+#include "threaddigest.h"
#include "types.h"
// Held by value: the parsed business-senders list is a member, and the load is
@@ -585,6 +586,22 @@ private slots:
/// Selects the remembered message once its thread's rows have loaded.
void applyPendingRecovery();
+
+ /// Selects \p messageId inside the currently selected conversation,
+ /// expanding it first (item 177).
+ ///
+ /// For the dashboard's unread entries. Deliberately NOT recoverStaleThread(),
+ /// which runs thread:<id> as a new query: the thread is already in the list
+ /// the user is looking at, and replacing that list to reach a row it
+ /// already holds would throw away the view they came from.
+ ///
+ /// A reply row does not exist until the expansion's replies arrive, so the
+ /// target is remembered and applied again when they do.
+ void selectMessageInCurrentThread(const QString &messageId);
+
+ /// Applies a pending selectMessageInCurrentThread() target, if any.
+ void applyPendingDashboardSelection();
+
void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void onQueryFinished(int total, quint64 generation);
void onThreadSelected(const QModelIndex &current, const QModelIndex &previous);
@@ -609,6 +626,15 @@ private slots:
/// Renders the single message a message row asked for.
void onMessageLoaded(const QVector<MessageRef> &messages,
quint64 generation);
+
+ /// Fills the conversation dashboard once the worker has read the digest.
+ ///
+ /// Guarded the way onMessageLoaded() is, and for the same reason: the
+ /// request crosses on a queued connection, so the answer arrives after
+ /// whatever the user did in the meantime. A digest for a thread the pane
+ /// has moved off, or one that arrives once the pane is showing a message,
+ /// must not repaint anything.
+ void onThreadDigestLoaded(const ThreadDigest &digest, quint64 generation);
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
@@ -1601,6 +1627,24 @@ private:
QString m_lastQuery;
QString m_currentThreadId;
+ /// The digest request's own generation, bumped per request.
+ ///
+ /// Separate from m_generation, which is the QUERY generation: bumping that
+ /// one discards any thread load in flight and blanks the pane, so a
+ /// selection asking for a digest would cancel the work the selection
+ /// itself started.
+ quint64 m_digestGeneration = 0;
+
+ /// The thread the dashboard is showing, empty whenever it is not showing.
+ /// A late digest is matched against this, not against m_currentThreadId,
+ /// which is also set for a thread of one message that renders normally.
+ QString m_dashboardThreadId;
+
+ /// The message a dashboard entry asked for and the thread it is in, both
+ /// empty when nothing is waiting. See selectMessageInCurrentThread().
+ QString m_dashboardSelectMessageId;
+ QString m_dashboardSelectThreadId;
+
/// The message a MESSAGE row is showing, empty whenever the pane holds a
/// whole thread. The two are mutually exclusive and each clears the other,
/// so a late reply can tell which kind of selection it belongs to.
diff --git a/src/messageview.cpp b/src/messageview.cpp
index eb0dccd..7106ab5 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -31,6 +31,7 @@
#include <QMenu>
#include <QMouseEvent>
#include <QPushButton>
+#include <QStackedWidget>
#include <QStandardPaths>
#include <QtNumeric>
#include <QResizeEvent>
@@ -57,6 +58,7 @@
#include "searchterm.h"
#include "tagstrip.h"
#include "threadcidmap.h"
+#include "threaddashboard.h"
#include "version.h"
namespace {
@@ -444,6 +446,11 @@ MessageView::MessageView(QWidget *parent)
// Tags live under the message rather than in the thread list, where
// spelling them out cost most of the list's width.
m_tagStrip = new TagStrip(this);
+ // Named because the pane now holds TWO strips: this one, under the
+ // message, and the dashboard's, under the conversation heading. An
+ // unqualified findChild<TagStrip *>() cannot tell them apart, and which
+ // one it happens to return is an ordering accident.
+ m_tagStrip->setObjectName(QStringLiteral("messageTagStrip"));
m_tagStrip->hide();
// Item 85: a tag chip is searchable. The strip reports which chip was hit
@@ -475,7 +482,12 @@ MessageView::MessageView(QWidget *parent)
// Left at the style's own default until then, which is what a MessageView
// built on its own in a test gets.
- auto *layout = new QVBoxLayout(this);
+ // Everything above belongs to ONE of the pane's two faces. The message
+ // page carries it; the dashboard is the other page of the stack.
+ m_messagePage = new QWidget(this);
+ m_messagePage->setObjectName(QStringLiteral("messagePage"));
+ auto *layout = new QVBoxLayout(m_messagePage);
+ layout->setContentsMargins(0, 0, 0, 0);
layout->addLayout(headerRow);
layout->addWidget(m_blockedBar);
layout->addWidget(m_receiveOnlyRibbon);
@@ -489,6 +501,27 @@ MessageView::MessageView(QWidget *parent)
layout->addWidget(m_attachmentBar);
layout->addWidget(m_tagStrip);
+ m_dashboard = new ThreadDashboard(this);
+
+ // A QStackedWidget takes the LARGEST minimum of its pages, so the hidden
+ // dashboard would set the floor for the whole pane: its three action
+ // buttons side by side measured 395px, against the 300px MainWindow asks
+ // for, and a floor the pane cannot go under is a floor that squeezes the
+ // thread list instead. The dashboard already scrolls, so letting it be
+ // narrower than its natural width costs nothing it does not already
+ // handle.
+ m_dashboard->setMinimumWidth(0);
+ m_dashboard->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+
+ m_stack = new QStackedWidget(this);
+ m_stack->addWidget(m_messagePage);
+ m_stack->addWidget(m_dashboard);
+ m_stack->setCurrentWidget(m_messagePage);
+
+ auto *outer = new QVBoxLayout(this);
+ outer->setContentsMargins(0, 0, 0, 0);
+ outer->addWidget(m_stack);
+
applyNoticeBarStyles();
clear();
@@ -519,9 +552,57 @@ void MessageView::setDocument(const QString &html)
m_view->setHtml(html, documentUrl());
}
+bool MessageView::showingDashboard() const
+{
+ return m_stack && m_stack->currentWidget() == m_dashboard;
+}
+
+void MessageView::showMessagePage()
+{
+ if (m_stack)
+ m_stack->setCurrentWidget(m_messagePage);
+}
+
+void MessageView::setDashboardThread(const QString &subject,
+ const QString &accountLabel,
+ const QStringList &tags)
+{
+ m_dashboard->setThreadHeading(subject, accountLabel);
+ m_dashboard->setTags(tags);
+}
+
+void MessageView::showDashboard(const ThreadDigest &digest)
+{
+ // Everything the message page was serving is dropped, exactly as
+ // showPlaceholder() drops it: the pane is no longer displaying that
+ // message, so none of its parts may stay reachable behind the dashboard.
+ m_items.clear();
+ m_tagStrip->setTags({});
+ m_cidHandler->setParts({});
+ m_interceptor->setAllowedCids({});
+ m_interceptor->resetForNewMessage();
+
+ m_headerLabel->clear();
+ m_detailsButton->hide();
+ m_blockedBar->hide();
+ m_messageBar->hide();
+ rebuildAttachmentBar();
+ setStaleThread(QString(), QString());
+
+ // Not the placeholder: a helper link is honoured only while THAT is on
+ // screen, and this is a different pane.
+ m_showingPlaceholder = false;
+ setDocument(QString());
+
+ m_dashboard->setDigest(digest);
+ m_stack->setCurrentWidget(m_dashboard);
+}
+
void MessageView::showPlaceholder(
const QList<HtmlBuilder::PlaceholderHelper> &helpers)
{
+ showMessagePage();
+
// Everything clear() drops, dropped again: this is reachable directly and
// must not leave a previous thread's parts serveable behind the logo.
m_items.clear();
@@ -633,6 +714,7 @@ void MessageView::setBarActions(const QList<QAction *> &messageActions,
void MessageView::clear()
{
+ showMessagePage();
m_items.clear();
m_showingPlaceholder = false;
m_tagStrip->setTags({});
@@ -668,6 +750,7 @@ void MessageView::clear()
void MessageView::showThread(const QList<ThreadRenderItem> &items)
{
+ showMessagePage();
m_items = items;
m_preferHtml = true;
m_showingPlaceholder = false;
@@ -707,6 +790,7 @@ void MessageView::showThread(const QList<ThreadRenderItem> &items)
void MessageView::showError(const QString &text, const QString &filePath)
{
+ showMessagePage();
m_items.clear();
m_showingPlaceholder = false;
diff --git a/src/messageview.h b/src/messageview.h
index c81a4f6..10b9430 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -29,10 +29,13 @@
#include "marks.h"
#include "mimeparser.h"
#include "searchterm.h"
+#include "threaddigest.h"
class QLabel;
class QMenu;
+class QStackedWidget;
class QToolBar;
+class ThreadDashboard;
class QWebEnginePage;
class QPushButton;
class QWebEngineView;
@@ -123,6 +126,29 @@ public:
/// re-render it with fresh counts without guessing what is on screen.
bool showingPlaceholder() const { return m_showingPlaceholder; }
+ /// Shows the conversation dashboard instead of a message (item 177).
+ ///
+ /// A thread row stands for the conversation, so there is no single message
+ /// to render. The heading, account and tags come from the summary and are
+ /// set separately: the digest is built from the index by the worker and
+ /// carries none of them.
+ void showDashboard(const ThreadDigest &digest);
+
+ /// The heading the dashboard displays. Separate from showDashboard()
+ /// because the summary is known at selection time and the digest arrives
+ /// afterwards, so the pane can be right before the round trip returns.
+ void setDashboardThread(const QString &subject, const QString &accountLabel,
+ const QStringList &tags);
+
+ /// True while the dashboard is what the pane is showing.
+ ///
+ /// The pane's content is asserted through this and through the dashboard's
+ /// own accessors, never by rendering: see "Rendering probes lie".
+ bool showingDashboard() const;
+
+ /// The dashboard itself, so the window can connect its signals. Never null.
+ ThreadDashboard *dashboard() const { return m_dashboard; }
+
/// Supplies the tag strip's colours. Not owned; must outlive the view.
void setTagColors(const TagColors *colours);
@@ -410,6 +436,17 @@ private:
/// Gates queryRequested(), so a link in a message body cannot run a query.
bool m_showingPlaceholder = false;
+ /// The two things this pane can be: a message, or the conversation it
+ /// belongs to. A stack rather than show/hide over eight widgets, so
+ /// switching cannot leave half of one pane visible behind the other.
+ QStackedWidget *m_stack = nullptr;
+ QWidget *m_messagePage = nullptr;
+ ThreadDashboard *m_dashboard = nullptr;
+
+ /// Puts the message page back on top. Every route that renders a message
+ /// passes through it, so the dashboard cannot survive into a message.
+ void showMessagePage();
+
QWebEngineProfile *m_profile = nullptr;
QWebEngineView *m_view = nullptr;
RequestInterceptor *m_interceptor = nullptr;
diff --git a/src/threaddashboard.cpp b/src/threaddashboard.cpp
index 8583379..56b587f 100644
--- a/src/threaddashboard.cpp
+++ b/src/threaddashboard.cpp
@@ -197,6 +197,9 @@ ThreadDashboard::ThreadDashboard(QWidget *parent) : QWidget(parent)
// Block 2: the tag chips. One tier, no muting, drawn by the same strip the
// message pane uses so the two cannot drift.
m_tags = new TagStrip;
+ // Named for the same reason the message pane's is: the two sit in one
+ // widget tree and an unqualified findChild cannot tell them apart.
+ m_tags->setObjectName(QStringLiteral("dashboardTagStrip"));
layout->addWidget(m_tags);
// Block 3: the counts, with the read-progress bar beneath.
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index d406b18..cba4c96 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -307,7 +307,7 @@ private slots:
void arrivingBatchesUpdateTheStatusBarWithTheCountSoFar();
void aRefreshsBatchesLeaveTheStatusBarAlone();
void selectingAThreadRootShowsItInTheMessagePane();
- void anUnexpandedRootRendersOneMessageNotTheConversation();
+ void anUnexpandedRootShowsTheDashboardLikeAnExpandedOne();
void aSingleMessageIdQuerysCardOpensInTheMessagePane();
void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
void autoSyncSkipsWhileABackgroundSyncIsRunning();
@@ -363,7 +363,7 @@ private slots:
void childRowsAreIndentedUnderTheirThread();
void aThreadWithRepliesDrawsAVisibleExpander();
void cardsNeverScrollSideways();
- void selectingARootCardKeepsItsThreadForMarkRead();
+ void selectingAConversationArmsNoMarkRead();
void nextThreadLeavesTheLastReply();
void altDownSkipsReplies();
void bothThreadStepBindingsReachTheAction();
@@ -404,6 +404,8 @@ private slots:
void taggingAnUnrelatedReplyLeavesTheStripAlone();
void aHeldMessageEditIsSentWhenTheSyncEnds();
void anActionOnAConversationRowTakesTheConversation();
+ void selectingAConversationShowsTheDashboard();
+ void selectingALoneMessageShowsTheMessage();
void autoMarkReadTouchesOnlyTheMessageOnDisplay();
void autoMarkReadArmsForAReplyToo();
void taggingTheOpenRootMessageKeepsTheStripPopulated();
@@ -1156,37 +1158,6 @@ NavFixture buildNavFixture(MainWindow &window)
} // namespace
-void TestMainWindow::selectingARootCardKeepsItsThreadForMarkRead()
-{
- // A root card is BOTH a message and a thread: it renders the thread's
- // first message, and it is still the thread that gets marked read and
- // repainted on a tag change. The message-row path deliberately clears the
- // current thread id; doing that here too would silently disable mark-read
- // and the tag-change repaint for every thread root in the list.
- const Config config;
- MainWindow window(config);
- window.show();
- QVERIFY(QTest::qWaitForWindowExposed(&window));
-
- const NavFixture f = buildNavFixture(window);
- f.view->setCurrentIndex(f.root);
- QApplication::processEvents();
-
- // Guard: the fixture loads replies, so the root knows its own message and
- // the branch under test is the one that runs.
- QVERIFY2(!f.model->data(f.root, ThreadListModel::MessageIdRole)
- .toString().isEmpty(),
- "the root card does not know its first message, so this exercises "
- "the fallback rather than the path it is written for");
-
- // A mark-read timer armed for the thread is what proves the thread id
- // survived: scheduleMarkRead() is only reached on the thread-row path.
- auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
- QVERIFY(timer);
- QVERIFY2(timer->isActive(),
- "no mark-read timer for a selected root card: its thread id was "
- "cleared along with the switch to rendering one message");
-}
void TestMainWindow::nextThreadLeavesTheLastReply()
{
@@ -3896,22 +3867,25 @@ void TestMainWindow::theStaleNoticeKeepsTheMessageOfAThreadRootToo()
queryEdit->setText(QStringLiteral("tag:unread"));
queryEdit->returnPressed();
+ // totalCount = 1 since item 177, and that is a retarget rather than a
+ // weakening. The stale notice describes the message the pane is DISPLAYING,
+ // and a conversation row displays none: it shows the dashboard. A thread of
+ // one still renders its message, which is the case the notice is for, and
+ // the root-sets-both-ids condition this test was written against is
+ // unchanged there.
ThreadSummary thread = makeThread(QStringLiteral("T1"),
{ QStringLiteral("unread") });
- thread.totalCount = 4;
+ thread.totalCount = 1;
model->appendBatch({ thread });
// The root knows its own message once the tree is loaded, which is what
- // makes the pane show one message rather than the conversation.
+ // makes the pane show one message rather than the conversation. One node,
+ // at depth 0: a thread of one has no replies to carry.
MessageNode root;
root.messageId = QStringLiteral("m0@example.org");
root.threadId = QStringLiteral("T1");
root.depth = 0;
- MessageNode reply;
- reply.messageId = QStringLiteral("m1@example.org");
- reply.threadId = QStringLiteral("T1");
- reply.depth = 1;
- model->setThreadMessages(QStringLiteral("T1"), { root, reply });
+ model->setThreadMessages(QStringLiteral("T1"), { root });
const QModelIndex threadIndex = model->index(0, 0, QModelIndex());
view->setCurrentIndex(threadIndex);
@@ -5772,7 +5746,10 @@ void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *strip = window.findChild<TagStrip *>();
+ // The MESSAGE pane's strip by name: the pane holds two since item 177,
+ // and an unqualified lookup can return the dashboard's instead.
+ auto *strip =
+ window.findChild<TagStrip *>(QStringLiteral("messageTagStrip"));
QVERIFY2(strip, "no tag strip in the message pane");
// visible + hidden: TagStrip collapses what does not fit into a "+N" chip,
@@ -5818,7 +5795,10 @@ void TestMainWindow::taggingAnUnrelatedReplyLeavesTheStripAlone()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *strip = window.findChild<TagStrip *>();
+ // The MESSAGE pane's strip by name: the pane holds two since item 177,
+ // and an unqualified lookup can return the dashboard's instead.
+ auto *strip =
+ window.findChild<TagStrip *>(QStringLiteral("messageTagStrip"));
QVERIFY(strip);
// visible + hidden: TagStrip collapses what does not fit into a "+N" chip,
@@ -5987,6 +5967,63 @@ void TestMainWindow::anActionOnAConversationRowTakesTheConversation()
QStringList{ QStringLiteral("t2-first@example.org") });
}
+void TestMainWindow::selectingAConversationShowsTheDashboard()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(model && view && pane);
+
+ ThreadSummary one = makeThread(QStringLiteral("t1"), {});
+ one.totalCount = 1;
+ ThreadSummary many = makeThread(QStringLiteral("t2"), {});
+ many.totalCount = 4;
+ model->appendBatch({ one, many });
+
+ // Before the gesture, so the check after it means something: the pane
+ // starts on the placeholder, not on a dashboard.
+ QVERIFY2(!pane->showingDashboard(),
+ "the pane was already showing a dashboard before anything was "
+ "selected, so the assertion below would pass on nothing");
+
+ selectThreadRow(view, 1);
+ QApplication::processEvents();
+
+ QVERIFY2(pane->showingDashboard(),
+ "selecting a conversation rendered a message: the row stands for "
+ "the thread and has no message to show");
+}
+
+void TestMainWindow::selectingALoneMessageShowsTheMessage()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(model && view && pane);
+
+ // The conversation FIRST, so a wrong answer cannot be accidentally right:
+ // a branch that showed the dashboard for every row would still be wrong
+ // here, and one that never showed it would be wrong above.
+ ThreadSummary many = makeThread(QStringLiteral("t1"), {});
+ many.totalCount = 4;
+ ThreadSummary one = makeThread(QStringLiteral("t2"), {});
+ one.totalCount = 1;
+ model->appendBatch({ many, one });
+
+ selectThreadRow(view, 1);
+ QApplication::processEvents();
+
+ QVERIFY2(!pane->showingDashboard(),
+ "a thread of one message showed a dashboard: it must open on one "
+ "click, which is the case the whole split exists to protect");
+}
+
void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnDisplay()
{
// Item 87, reported 2026-08-14: "with the first message in a thread
@@ -6018,9 +6055,19 @@ void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnDisplay()
auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
QVERIFY(timer);
+ // totalCount = 1 since item 177, and the retarget is what KEEPS this test
+ // able to fail. A thread row means the conversation now and displays no
+ // message, so it arms no mark-read at all and there is no write left here
+ // to be wrong about. A thread of one still renders its message, which is
+ // where the automatic mark-read survives and where the scoping above is
+ // still the property that matters.
+ //
+ // The stronger new rule, that a conversation row arms nothing whatever, is
+ // asserted by selectingAConversationArmsNoMarkRead() below. Between them
+ // the two cover every row an automatic mark-read can reach.
ThreadSummary t = makeThread(QStringLiteral("t1"),
{ QStringLiteral("unread") });
- t.totalCount = 7;
+ t.totalCount = 1;
model->appendBatch({ t });
selectThreadRow(view, 0);
@@ -6032,21 +6079,114 @@ void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnDisplay()
QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 2000);
QApplication::processEvents();
- // ONE message, the one the card renders, and named rather than merely
- // counted: a thread of seven whose first message is the target is exactly
- // the case where a count of one could still be the wrong one.
+ // ONE message, the one the card renders, and NAMED rather than merely
+ // counted: a count of one can still be the wrong one, and the id is what
+ // says the write landed on the message actually on display.
QCOMPARE(window.pendingMessageIdsForTesting(),
QStringList{ QStringLiteral("t1-first@example.org") });
QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(),
- "the automatic mark-read still wrote to the whole thread, so six "
- "messages the user never displayed were marked read and the next "
- "sync carries that to the server");
+ "the automatic mark-read escalated to the whole thread, so mail "
+ "the user never displayed was marked read and the next sync "
+ "carries that to the server");
// Still not on the undo stack. The user never took this action, so
// hijacking Ctrl+Z to reverse it would undo something they did not do.
QCOMPARE(window.undoDepthForTesting(), 0);
}
+void TestMainWindow::selectingAConversationArmsNoMarkRead()
+{
+ // Item 177, and it REPLACES selectingARootCardKeepsItsThreadForMarkRead(),
+ // which asserted the opposite and is retired. That test opened "A root card
+ // is BOTH a message and a thread", which is exactly the identity item 177
+ // splits: a row with replies is the conversation and displays no message,
+ // so there is nothing on screen the user can be said to have read.
+ // Retargeting it would have made it a test about something it was never
+ // about; the history is recorded here so the next reader finds it.
+ //
+ // The data-safety half of item 87 is unchanged and still lives in
+ // autoMarkReadTouchesOnlyTheMessageOnDisplay() above, on the row that does
+ // display a message. This is the stronger half: arming NOTHING is safe by
+ // construction, so the assertions below are about the absence of any write
+ // rather than about its scope.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[general]\nmark_read_delay_ms = 0\n");
+ file.close();
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.markReadDelayMs(), 0);
+
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+
+ // A thread of ONE first and the conversation SECOND, in opposite states,
+ // so a wrong answer is visible rather than accidentally right: code that
+ // armed nothing anywhere would fail the lone-message test above, and code
+ // that armed for everything fails here.
+ ThreadSummary lone = makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") });
+ lone.totalCount = 1;
+ ThreadSummary conversation = makeThread(QStringLiteral("t2"),
+ { QStringLiteral("unread") });
+ conversation.totalCount = 7;
+ model->appendBatch({ lone, conversation });
+
+ // The guard: this fixture really does arm a mark-read for the row that
+ // displays a message, so the absence asserted below is the conversation
+ // row's doing and not a window that never arms anything.
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+ QVERIFY2(timer->isActive() || !window.pendingMessageIdsForTesting().isEmpty(),
+ "the lone-message row armed nothing either, so this test cannot "
+ "tell a conversation row from a broken mark-read");
+
+ QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 2000);
+ QApplication::processEvents();
+
+ // A fresh window, so the lone row's own write does not count towards the
+ // assertions about the conversation.
+ MainWindow second(config);
+ auto *secondModel = second.findChild<ThreadListModel *>();
+ QVERIFY(secondModel);
+ auto *secondView = second.findChild<QTreeView *>();
+ QVERIFY(secondView);
+ auto *secondTimer =
+ second.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(secondTimer);
+ secondModel->appendBatch({ lone, conversation });
+
+ selectThreadRow(secondView, 1);
+ QApplication::processEvents();
+
+ QVERIFY2(!secondTimer->isActive(),
+ "a conversation row armed the mark-read timer, so a row that "
+ "displays no message is about to mark one read");
+
+ // Given time to fire, in case it was armed and stopped between the check
+ // above and here. Nothing may arrive.
+ QTest::qWait(50);
+ QApplication::processEvents();
+
+ QVERIFY2(second.pendingMessageIdsForTesting().isEmpty(),
+ "a conversation row marked a message read: the row displays "
+ "none, so the write named a message the user never saw");
+ QVERIFY2(second.pendingThreadIdsForTesting().isEmpty(),
+ "a conversation row marked the whole thread read, which is "
+ "every message in it and none of them displayed");
+ QCOMPARE(second.undoDepthForTesting(), 0);
+}
+
void TestMainWindow::autoMarkReadArmsForAReplyToo()
{
// Selecting a reply displays that message, so the same rule applies to it.
@@ -6123,7 +6263,10 @@ void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *strip = window.findChild<TagStrip *>();
+ // The MESSAGE pane's strip by name: the pane holds two since item 177,
+ // and an unqualified lookup can return the dashboard's instead.
+ auto *strip =
+ window.findChild<TagStrip *>(QStringLiteral("messageTagStrip"));
QVERIFY(strip);
ThreadSummary t = makeThread(QStringLiteral("t1"),
@@ -6185,7 +6328,10 @@ void TestMainWindow::aLoadedMessageCorrectsTheStripFromTheThreadsUnion()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *strip = window.findChild<TagStrip *>();
+ // The MESSAGE pane's strip by name: the pane holds two since item 177,
+ // and an unqualified lookup can return the dashboard's instead.
+ auto *strip =
+ window.findChild<TagStrip *>(QStringLiteral("messageTagStrip"));
QVERIFY(strip);
const auto stripTags = [strip]() {
@@ -6193,11 +6339,20 @@ void TestMainWindow::aLoadedMessageCorrectsTheStripFromTheThreadsUnion()
};
// The union carries `signed`; the root message does not.
+ //
+ // totalCount = 1 since item 177, which is a retarget rather than a
+ // weakening. This test is about the message pane's strip being corrected
+ // from a LOADED message, and a conversation row displays no message at all
+ // now: it shows the dashboard, whose strip is the thread's union on
+ // purpose. A thread of one still renders its message, so the union and the
+ // message's own tags can still disagree and the correction still has to
+ // happen. The tags are unchanged, so the disagreement the test turns on is
+ // the same one the user reported.
ThreadSummary t = makeThread(QStringLiteral("t1"),
{ QStringLiteral("inbox"),
QStringLiteral("signed"),
QStringLiteral("unread") });
- t.totalCount = 4;
+ t.totalCount = 1;
model->appendBatch({ t });
selectThreadRow(view, 0);
@@ -7845,8 +8000,14 @@ void TestMainWindow::theMessageBarSitsAboveTheBodyAndBelowTheHeader()
auto *header = pane->findChild<QLabel *>(QStringLiteral("messageHeader"));
QVERIFY(bar);
- auto *layout = qobject_cast<QVBoxLayout *>(pane->layout());
- QVERIFY2(layout, "the message pane is not laid out vertically");
+ // The message PAGE's column, not the pane's. Since item 177 the pane is a
+ // stack of two faces, the message and the conversation dashboard, so the
+ // pane's own layout holds one item and the ordering this test is about
+ // lives one level in.
+ auto *page = pane->findChild<QWidget *>(QStringLiteral("messagePage"));
+ QVERIFY2(page, "the message pane has no message page to order");
+ auto *layout = qobject_cast<QVBoxLayout *>(page->layout());
+ QVERIFY2(layout, "the message page is not laid out vertically");
// Index in the pane's own column, which is what "above" and "below" mean
// here. Asserting on geometry instead would measure the offscreen
@@ -10776,13 +10937,23 @@ void TestMainWindow::selectingAThreadRootShowsItInTheMessagePane()
QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
}
-void TestMainWindow::anUnexpandedRootRendersOneMessageNotTheConversation()
+void TestMainWindow::anUnexpandedRootShowsTheDashboardLikeAnExpandedOne()
{
- // Item 66, the half that reproduced. Clicking a thread root that has never
- // been expanded used to render the whole conversation, because the model
- // learned the thread's first message only when the replies loaded. The
- // identical click rendered ONE message afterwards. The user reported the
- // inconsistency and asked for the single-message behaviour throughout.
+ // Item 66 resolved the other way, by item 177, and this REPLACES
+ // anUnexpandedRootRendersOneMessageNotTheConversation().
+ //
+ // What item 66 was actually about was an INCONSISTENCY across the
+ // expansion boundary: clicking a thread root that had never been expanded
+ // rendered the whole conversation, and the identical click rendered one
+ // message afterwards, because the model learned the thread's first message
+ // only when the replies loaded. The user reported the inconsistency. Item
+ // 66 removed it by making both clicks render one message; item 177 removes
+ // it the other way, by making a row with replies mean the conversation
+ // whether or not it has been opened.
+ //
+ // So the old assertion is now simply the wrong expectation, while item
+ // 66's real value, that the same gesture does the same thing on both sides
+ // of the expansion, is exactly what this asserts.
WorkerBackedWindow backed;
QVERIFY(backed.fixture().addMessage(
QStringLiteral("inbox"), QStringLiteral("root@example.org"),
@@ -10817,27 +10988,36 @@ void TestMainWindow::anUnexpandedRootRendersOneMessageNotTheConversation()
const QModelIndex root = model->index(0, 0, QModelIndex());
QVERIFY(root.isValid());
QVERIFY2(model->hasChildren(root), "the two messages did not thread");
+ QVERIFY2(model->isConversationRow(root),
+ "the two-message row is not a conversation, so this measures the "
+ "lone-message path instead");
- // NEVER expanded. That is the whole point: this is the state in which the
- // old code fell back to the conversation render.
+ // NEVER expanded yet. That is the side of the boundary item 66 found the
+ // inconsistency on.
QVERIFY2(!view->isExpanded(root), "the test expanded the thread itself");
- // The root's message id is known anyway, because the query carries it now.
- QVERIFY2(!model->data(root, ThreadListModel::MessageIdRole)
- .toString()
- .isEmpty(),
- "an unexpanded root still has no message id: the query is not "
- "carrying firstMessageId");
+ // The guard: the pane is not already showing a dashboard, or the
+ // assertion after the click would pass on nothing.
+ QVERIFY(!pane->showingDashboard());
view->setCurrentIndex(root);
- QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+ QApplication::processEvents();
+
+ QVERIFY2(pane->showingDashboard(),
+ "an unexpanded conversation row rendered a message: the row "
+ "stands for the thread whether or not it has been opened");
+
+ // The other side of the boundary, which is the whole point: expanding
+ // changes what the LIST shows and must not change what the ROW means.
+ view->expand(root);
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(root) > 0, 15000);
+
+ view->setCurrentIndex(root);
+ QApplication::processEvents();
- // ONE message, not a conversation. headerSearchOffers() is populated only
- // when the header states a single message's own From/To/Cc; for a thread
- // the header says "N messages in thread" and carries no such offers, so an
- // empty list here is exactly the conversation render this replaced.
- QVERIFY2(!pane->headerSearchOffers().isEmpty(),
- "the pane rendered a conversation, not a single message");
+ QVERIFY2(pane->showingDashboard(),
+ "the same click on the same row did different things either side "
+ "of the expansion, which is the inconsistency item 66 reported");
}
void TestMainWindow::aSingleMessageIdQuerysCardOpensInTheMessagePane()