aboutsummaryrefslogtreecommitdiffstats
path: root/src/mainwindow.cpp
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 /src/mainwindow.cpp
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.
Diffstat (limited to 'src/mainwindow.cpp')
-rw-r--r--src/mainwindow.cpp179
1 files changed, 179 insertions, 0 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())