diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-28 19:00:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-28 19:00:59 +0200 |
| commit | ae74237ca11640ceb68887b2c6d09ecf1befb342 (patch) | |
| tree | 6ae37c5ed27eeb27c55a17febe16d03fa3553503 | |
| parent | 46acb489691e5298181ce52bfe847889ee06fe68 (diff) | |
| download | qtmaildir-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.cpp | 179 | ||||
| -rw-r--r-- | src/mainwindow.h | 44 | ||||
| -rw-r--r-- | src/messageview.cpp | 86 | ||||
| -rw-r--r-- | src/messageview.h | 37 | ||||
| -rw-r--r-- | src/threaddashboard.cpp | 3 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 330 |
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 ¤t, 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 ¤t, 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 ¤t, 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() |
