summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp37
-rw-r--r--src/config.h31
-rw-r--r--src/mainwindow.cpp50
-rw-r--r--src/mainwindow.h28
-rw-r--r--src/mimeparser.cpp59
-rw-r--r--src/mimeparser.h17
-rw-r--r--src/notmuchworker.cpp57
-rw-r--r--src/notmuchworker.h15
-rw-r--r--src/threadlistmodel.cpp39
-rw-r--r--src/threadlistmodel.h19
-rw-r--r--src/types.h12
11 files changed, 355 insertions, 9 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 9e223f8..47fadec 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -47,6 +47,37 @@ QString Account::scopedQuery(const QString &query) const
return QStringLiteral("%1 and (%2)").arg(prefix, query);
}
+QString Account::sentQuery() const
+{
+ if (sent.isEmpty())
+ return QString();
+
+ // The QUOTES are load-bearing, not decoration. A real provider nests its
+ // sent folder under a bracketed parent, "[Provider]/Posta inviata", and
+ // "[" and "]" are Xapian syntax: unquoted, the term is parsed rather than
+ // matched and the query silently returns nothing while looking correct.
+ //
+ // The path is user config and is interpolated into a query, so this is the
+ // only place that composition happens; a caller building it by hand would
+ // be a second chance to forget the quotes.
+ return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, sent);
+}
+
+QString Config::allSentQuery() const
+{
+ // Collect first, join after. Appending "or" per account and trimming the
+ // result is the version that produced the defect this guards: an account
+ // with no sent key contributes an empty term, notmuch accepts the bare
+ // "or" without complaint, and the query quietly means something else.
+ QStringList parts;
+ for (const Account &account : m_accounts) {
+ const QString query = account.sentQuery();
+ if (!query.isEmpty())
+ parts.append(query);
+ }
+ return parts.join(QStringLiteral(" or "));
+}
+
QString Config::defaultPath()
{
const QString base =
@@ -281,6 +312,12 @@ void Config::load(const QString &path)
account.maildir = settings.value(QStringLiteral("maildir")).toString();
account.drafts = settings.value(QStringLiteral("drafts")).toString();
+ // Optional, and absent for an account that keeps no sent mail locally.
+ // Trimmed because a trailing space would land inside the quoted path
+ // and match nothing, which is invisible in a config file.
+ account.sent =
+ settings.value(QStringLiteral("sent")).toString().trimmed();
+
// Both optional, and both describe this account's chip in the thread
// list. An account tag is a different taxonomy from a functional one,
// saying which mailbox a thread arrived in rather than what state it
diff --git a/src/config.h b/src/config.h
index bcbfb34..e3c5b6e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -36,6 +36,18 @@ struct Account
QString maildir; ///< Relative to notmuch's database.path.
QString drafts; ///< Unused in v1; send is v2.
+ /// The account's sent folder, relative to maildir. Optional and empty for
+ /// an account that has none, which is a real case rather than a
+ /// misconfiguration: an account may keep no sent mail locally at all.
+ ///
+ /// A key rather than a <maildir>/Sent convention because the folder is not
+ /// uniform across providers. Measured across one real setup: two accounts
+ /// use `Sent`, two nest a localised name under a bracketed parent
+ /// (`[Provider]/Posta inviata`), and one has no sent folder whatsoever. A
+ /// convention would produce an empty view for the nested ones and a wrong
+ /// one for the account that has none.
+ QString sent;
+
/// Chip colour in the thread list. Invalid when unset, in which case one
/// is generated from the account tag's name.
QColor color;
@@ -62,6 +74,13 @@ struct Account
/// Restricts a notmuch query to this account's subtree.
QString scopedQuery(const QString &query) const;
+
+ /// Matches this account's sent mail, or empty when `sent` is unset.
+ ///
+ /// Composes with scopedQuery() rather than replacing it: the account
+ /// selector wraps whatever query runs, so a Sent view under one account
+ /// intersects to that account's sent mail and cannot leak another's.
+ QString sentQuery() const;
};
struct SavedQuery
@@ -114,6 +133,18 @@ public:
/// Optional alternate notmuch config file. Empty means "let notmuch decide".
QString notmuchConfig() const { return m_notmuchConfig; }
+ /// Matches every configured account's sent mail, or empty when no account
+ /// configures one.
+ ///
+ /// Joins only the NON-EMPTY sentQuery() results. An account without a
+ /// `sent` key contributes nothing, and joining it anyway would leave a bare
+ /// `or` in the query. notmuch does not reject that: `A or or B` returns
+ /// 190 messages where the correct pair returns 211, measured directly. A
+ /// malformed query that still returns plausible mail is the failure that
+ /// ships, which is why the join lives here and is tested rather than being
+ /// open-coded at the call site.
+ QString allSentQuery() const;
+
/// A QDateTime::toString() pattern for the date on a card, or empty for the
/// system locale's short format.
///
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 374f228..be6cfdb 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -457,6 +457,7 @@ void MainWindow::buildUi()
this, &MainWindow::runCurrentQuery);
m_queryEdit = new QLineEdit(central);
+ m_queryEdit->setObjectName(QStringLiteral("queryEdit"));
m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox"));
// Qt draws the clear button inside the field and shows it only when there
// is text, themed by the desktop. A hand-rolled button beside the bar would
@@ -553,6 +554,28 @@ void MainWindow::buildUi()
});
queryRow->addWidget(button);
}
+
+ // Sent sits with the saved queries and is not one: its query is COMPOSED
+ // from the accounts' `sent` keys at click time, so adding an account or
+ // correcting a folder name is a config edit and nothing else. A [queries]
+ // entry holding the same string would go stale silently, and could not
+ // narrow to the selected account the way this does through
+ // runCurrentQuery()'s existing scope wrap.
+ //
+ // Hidden entirely when no account configures a sent folder, rather than
+ // offering a button that always finds nothing.
+ if (!m_config.allSentQuery().isEmpty()) {
+ auto *sentButton = new QPushButton(tr("Sent"), central);
+ sentButton->setObjectName(QStringLiteral("sentButton"));
+ connect(sentButton, &QPushButton::clicked, this, [this]() {
+ m_queryEdit->setText(m_config.allSentQuery());
+ // Flat for this query only. runCurrentQuery() clears it again for
+ // anything else, including the same query typed by hand, so the
+ // flag cannot outlive the button that set it.
+ runQuery(FlatResult::Yes);
+ });
+ queryRow->addWidget(sentButton);
+ }
layout->addLayout(queryRow);
// Thread list and message pane.
@@ -1471,8 +1494,14 @@ void MainWindow::showWarnings()
problems.join(QLatin1Char('\n')));
}
-void MainWindow::runCurrentQuery()
+void MainWindow::runQuery(FlatResult flat)
{
+ // Set on EVERY run, not only when Yes. This is the line that stops flat
+ // mode leaking: any query that is not the Sent button restores the tree,
+ // so the flag cannot survive into the next view.
+ m_sentView = flat == FlatResult::Yes;
+ m_model->setFlatMode(m_sentView);
+
QString query = m_queryEdit->text().trimmed();
const QString accountKey = m_accountBox->currentData().toString();
@@ -1516,10 +1545,13 @@ void MainWindow::runCurrentQuery()
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
+ // Recipients only for the Sent view: the fold reads message FILES, which
+ // is tens of seconds over an inbox. See ThreadSummary::recipients.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, query),
Q_ARG(quint64, m_generation),
- Q_ARG(NotmuchWorker::SortOrder, sort));
+ Q_ARG(NotmuchWorker::SortOrder, sort),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads,
@@ -1863,10 +1895,16 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
+ // In the Sent view the pane shows only what matched, which is what the
+ // user sent. Without this the flat list is right and the pane still opens
+ // the whole conversation, replies included, under a heading that says
+ // Sent: the row was never expanded, so the model never learned the
+ // thread's first message and this is the only path a Sent row can take.
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
- Q_ARG(quint64, m_generation));
+ Q_ARG(quint64, m_generation),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
@@ -2250,10 +2288,14 @@ void MainWindow::refreshCurrentQuery()
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
+ // The SAME recipients flag the visible view was built with. A refresh that
+ // dropped it would quietly replace a Sent view's recipients with empty
+ // strings on the first background sync, while the user was reading it.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, m_lastQuery),
Q_ARG(quint64, m_refreshGeneration),
- Q_ARG(NotmuchWorker::SortOrder, sort));
+ Q_ARG(NotmuchWorker::SortOrder, sort),
+ Q_ARG(bool, m_sentView));
}
void MainWindow::updateStaleThreadNotice()
diff --git a/src/mainwindow.h b/src/mainwindow.h
index a7ea5c3..6e90ba1 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -173,8 +173,25 @@ protected:
/// this the action fires from inside the bar and the query never runs.
bool eventFilter(QObject *watched, QEvent *event) override;
+public:
+ /// Whether the result of a query is shown as a flat list of threads rather
+ /// than as an expandable tree.
+ ///
+ /// Only the Sent button asks for Yes. Every other route runs the no-arg
+ /// slot, which passes No, so flat mode cannot outlive the view that asked
+ /// for it: the same query typed by hand comes back as a tree.
+ enum class FlatResult { No, Yes };
+ Q_ENUM(FlatResult)
+
+private:
+ /// The real query runner. Kept off the slot list deliberately: a slot with
+ /// a defaulted argument does not satisfy QObject::connect, which matches
+ /// signal and slot arity at compile time, so the zero-argument slot below
+ /// is what widgets connect to.
+ void runQuery(FlatResult flat);
+
private slots:
- void runCurrentQuery();
+ void runCurrentQuery() { runQuery(FlatResult::No); }
/// Brings back a thread that stopped matching, and restores the reader's
/// place inside it.
@@ -642,6 +659,15 @@ private:
/// the whole result set rather than the batches that have arrived so far.
/// Gates mark_all_read, which cannot honestly say "all" before then.
bool m_queryComplete = false;
+
+ /// Whether the CURRENT view is the Sent one, and therefore whether it is
+ /// flat and carries recipients.
+ ///
+ /// Held rather than recomputed because a background refresh re-runs the
+ /// same query without going through the Sent button: without this, the
+ /// first cron sync would silently turn a Sent view back into a tree of
+ /// senders while the user was reading it.
+ bool m_sentView = false;
QString m_lastQuery;
QString m_currentThreadId;
diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp
index 4ad617e..ecf56f1 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -213,6 +213,65 @@ QString Attachment::saveWithoutOverwriting(const QString &directory,
return target;
}
+QString recipientSummary(const QString &rawTo, int maxNames)
+{
+ const QByteArray utf8 = rawTo.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return {};
+
+ // Returns NULL rather than an empty list for input it cannot make anything
+ // of, including the empty string. Guarded above and again here: the header
+ // is untrusted and this is the crash if it is not.
+ InternetAddressList *list = internet_address_list_parse(nullptr,
+ utf8.constData());
+ if (!list)
+ return {};
+
+ QStringList names;
+ int total = 0;
+ const int count = internet_address_list_length(list);
+ for (int i = 0; i < count; ++i) {
+ InternetAddress *address = internet_address_list_get_address(list, i);
+ if (!address)
+ continue;
+
+ // A group (`undisclosed-recipients:;`) has a name but no address, and
+ // its members, if any, are a list of their own. Only mailboxes are
+ // counted: naming the group would print "undisclosed-recipients" as
+ // though it were a person.
+ if (!INTERNET_ADDRESS_IS_MAILBOX(address))
+ continue;
+
+ ++total;
+ if (names.size() >= maxNames)
+ continue;
+
+ const char *name = internet_address_get_name(address);
+ const QString display = name ? QString::fromUtf8(name).trimmed()
+ : QString();
+ if (!display.isEmpty()) {
+ names.append(display);
+ continue;
+ }
+ const char *addr = internet_address_mailbox_get_addr(
+ INTERNET_ADDRESS_MAILBOX(address));
+ if (addr)
+ names.append(QString::fromUtf8(addr));
+ else
+ --total; // Neither a name nor an address: nothing to show.
+ }
+ g_object_unref(list);
+
+ if (names.isEmpty())
+ return {};
+
+ QString summary = names.join(QStringLiteral(", "));
+ const int hidden = total - names.size();
+ if (hidden > 0)
+ summary += QStringLiteral(" +%1").arg(hidden);
+ return summary;
+}
+
QString attachmentFolderName(const QString &rfc822Date, const QString &subject)
{
// The date prefix sorts chronologically in a file manager. A Date: header
diff --git a/src/mimeparser.h b/src/mimeparser.h
index af7619f..9fceb2f 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -94,6 +94,23 @@ struct Attachment
/// subject can be far longer than that.
QString attachmentFolderName(const QString &rfc822Date, const QString &subject);
+/// A one-line summary of a raw To: header, for the sender's place on a card in
+/// a Sent view.
+///
+/// Parsed with GMime rather than split on commas: a display name may CONTAIN a
+/// comma, so `"Rossi, Mario" <m@example.org>, info@example.net` is two
+/// addresses and naive splitting reports three.
+///
+/// Each address renders as its display name, falling back to the address when
+/// it has none, so a list does not mix "Mario Rossi" with a bare address. With
+/// more than `maxNames` recipients the rest collapse into "+N", mirroring the
+/// tag strip's overflow chip rather than being elided mid-name.
+///
+/// The header is untrusted and may be empty, malformed, or a group such as
+/// `undisclosed-recipients:;`. Returns an empty string when nothing usable can
+/// be read, never a partial parse.
+QString recipientSummary(const QString &rawTo, int maxNames = 2);
+
struct ParsedMessage
{
bool ok = false;
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index d34c032..24f3fd9 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -24,6 +24,7 @@
#include <cstdlib>
+#include "mimeparser.h"
#include "nmraii.h"
namespace {
@@ -46,6 +47,45 @@ QStringList tagsOf(notmuch_thread_t *thread)
return result;
}
+/// Who a thread's messages were addressed to, summarised for one line.
+///
+/// EXPENSIVE, and only called when a query asks. "To" is not served from
+/// notmuch's index, so every call here reads message FILES: 8.7 ms per thread
+/// measured against a real database, which is 38 seconds over a 4411-thread
+/// inbox. See ThreadSummary::recipients.
+///
+/// The messages come from the THREAD and are owned by it, freed when it is
+/// freed (notmuch.h:1637). They are therefore held raw and never wrapped in
+/// NmMessage, which would call notmuch_message_destroy on memory the thread
+/// frees again, and the walk finishes before the caller drops the thread. This
+/// is the same rule walkReplies follows, and getting it wrong is a double-free
+/// rather than a leak.
+QString recipientsOf(notmuch_thread_t *thread)
+{
+ // The first message with a usable To wins. A thread is one conversation,
+ // and the alternative, folding every message's recipients together, is the
+ // participants-list problem item 2 rejected: it produces a union that
+ // misdescribes itself the moment a thread has replies going both ways.
+ notmuch_messages_t *messages = notmuch_thread_get_messages(thread);
+ for (; notmuch_messages_valid(messages);
+ notmuch_messages_move_to_next(messages)) {
+ notmuch_message_t *message = notmuch_messages_get(messages);
+ if (!message)
+ continue;
+
+ // Returns "" for a missing header and NULL on error, and the two mean
+ // different things only to notmuch: both are "nothing to show" here.
+ const char *to = notmuch_message_get_header(message, "To");
+ if (!to || !*to)
+ continue;
+
+ const QString summary = recipientSummary(QString::fromUtf8(to));
+ if (!summary.isEmpty())
+ return summary;
+ }
+ return QString();
+}
+
/// Collects the message ids a query matches. Returns false if the query could
/// not be run at all, which is different from a query that matched nothing.
bool collectMessageIds(notmuch_database_t *db, const QString &query,
@@ -187,7 +227,7 @@ void NotmuchWorker::close()
}
void NotmuchWorker::runQuery(const QString &query, quint64 generation,
- SortOrder sort)
+ SortOrder sort, bool withRecipients)
{
if (!openReadOnly())
return;
@@ -231,6 +271,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
summary.totalCount = notmuch_thread_get_total_messages(thread.get());
summary.matchedCount = notmuch_thread_get_matched_messages(thread.get());
summary.tags = tagsOf(thread.get());
+ if (withRecipients)
+ summary.recipients = recipientsOf(thread.get());
batch.append(summary);
++total;
@@ -250,7 +292,7 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
void NotmuchWorker::loadThread(const QString &threadId,
const QString &matchQuery,
- quint64 generation)
+ quint64 generation, bool matchedOnly)
{
if (!openReadOnly())
return;
@@ -303,6 +345,17 @@ void NotmuchWorker::loadThread(const QString &threadId,
ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get()));
ref.tags = tagsOf(message.get());
ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId);
+
+ // Dropped rather than rendered as a stub.
+ //
+ // haveMatchSet is redundant here and kept deliberately: ref.matched is
+ // already true for every message when no query was given, so the two
+ // conditions cannot disagree today. It states the invariant this
+ // depends on at the point that depends on it, so a later change to how
+ // ref.matched is computed cannot silently empty the pane.
+ if (matchedOnly && haveMatchSet && !ref.matched)
+ continue;
+
result.append(ref);
}
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 1d8c8c0..2dd766f 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -60,14 +60,25 @@ public:
public slots:
/// Runs a query. generation lets the UI discard results from a superseded
/// query without the worker needing to know about cancellation.
+ /// `withRecipients` fills ThreadSummary::recipients by reading each
+ /// thread's To headers. OFF by default and deliberately opt-in: To is not
+ /// in notmuch's index, so this reads message FILES, at roughly 8.7 ms per
+ /// thread. Only a Sent query asks for it; turning it on for an inbox query
+ /// costs tens of seconds and changes nothing a user can see.
void runQuery(const QString &query, quint64 generation,
- SortOrder sort = NewestFirst);
+ SortOrder sort = NewestFirst, bool withRecipients = false);
/// Loads the messages of one thread, oldest first. matchQuery is the
/// user's current query; messages matching it render expanded, the rest
/// as stubs.
+ /// `matchedOnly` drops the messages that did not match `matchQuery` rather
+ /// than rendering them as stubs. For the Sent view, where the thread is not
+ /// the unit the user is reading: a sent message pulls in the replies it
+ /// received, and a pane claiming to show what they sent then shows a
+ /// conversation. Ignored when `matchQuery` is empty, since nothing was
+ /// filtered and every message counts as matched.
void loadThread(const QString &threadId, const QString &matchQuery,
- quint64 generation);
+ quint64 generation, bool matchedOnly = false);
/// Loads a thread as a reply TREE, for the message rows in the list.
///
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 33356f0..5874648 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -176,6 +176,21 @@ QModelIndex ThreadListModel::parent(const QModelIndex &child) const
return createIndex(static_cast<int>(id), 0, static_cast<quintptr>(-1));
}
+void ThreadListModel::setFlatMode(bool flat)
+{
+ if (m_flatMode == flat)
+ return;
+
+ // A full reset, not dataChanged. Flat mode changes what hasChildren() and
+ // rowCount() answer for every thread row, and a view that has already
+ // expanded one is holding indexes below it: dataChanged says "these rows
+ // are different", not "the shape under them is gone", and leaves the view
+ // drawing children the model no longer offers.
+ beginResetModel();
+ m_flatMode = flat;
+ endResetModel();
+}
+
int ThreadListModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid())
@@ -190,6 +205,11 @@ int ThreadListModel::rowCount(const QModelIndex &parent) const
if (parent.row() < 0 || parent.row() >= m_threads.size())
return 0;
+ // Hidden, not discarded: leaving flat mode restores the tree with no
+ // reload, and an expansion loaded before the switch is still there.
+ if (m_flatMode)
+ return 0;
+
return m_threads.at(parent.row()).children.size();
}
@@ -209,6 +229,10 @@ bool ThreadListModel::hasChildren(const QModelIndex &parent) const
if (parent.row() < 0 || parent.row() >= m_threads.size())
return false;
+ // No expander in a flat list, whatever the thread turns out to contain.
+ if (m_flatMode)
+ return false;
+
const ThreadNode &node = m_threads.at(parent.row());
// Once loaded the children are the truth, including "there are none", which
@@ -469,6 +493,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// twice on the same card.
return thread.subject;
case SendersRole:
+ // Recipients in their place when the query supplied them, which only a
+ // Sent query does. The sender there is the user on every row and says
+ // nothing; who it went TO is the question the view exists to answer.
+ //
+ // Falls back to authors when the fold found no usable To, so a message
+ // with a malformed or absent recipient header shows the sender rather
+ // than a blank line where a name belongs.
+ if (!thread.recipients.isEmpty())
+ return thread.recipients;
return thread.authors;
case DateRole:
// The QDateTime itself. Formatting belongs to the delegate now: the
@@ -480,6 +513,12 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
case IsFlaggedRole:
return thread.isFlagged();
case ReplyCountRole:
+ // Zero in a flat list, so the card draws no expander pill. The count
+ // and hasChildren() must agree: a card advertising "3 replies" that
+ // cannot be opened is the inert-glyph defect this project has already
+ // shipped once.
+ if (m_flatMode)
+ return 0;
// totalCount includes the root message, which is the card itself.
return qMax(0, thread.totalCount - 1);
case DateFormatRole:
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 777841e..8b8f414 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -170,6 +170,24 @@ public:
/// The pattern DateFormatRole answers with. Empty means the system format.
void setDateFormat(const QString &format) { m_dateFormat = format; }
+ /// One row per thread, with no expander and no reply count.
+ ///
+ /// For the Sent view, where a thread is the wrong unit: the user's model of
+ /// "what I sent" is a list, and a matching sent message otherwise drags in
+ /// the replies they RECEIVED, under a view that claims to be their outbox.
+ ///
+ /// A flag on this model rather than a second model or a filtered query, and
+ /// that is what keeps it from leaking: it is off by default, only the Sent
+ /// button turns it on, and every other query turns it off again. The
+ /// expander already comes from hasChildren() and the card's count from
+ /// ReplyCountRole, so flat mode is those two answering differently and
+ /// nothing else changes.
+ ///
+ /// The children are not discarded, only hidden. Leaving flat mode restores
+ /// the tree without reloading anything.
+ void setFlatMode(bool flat);
+ bool flatMode() const { return m_flatMode; }
+
QModelIndex index(int row, int column,
const QModelIndex &parent = {}) const override;
QModelIndex parent(const QModelIndex &child) const override;
@@ -284,4 +302,5 @@ private:
QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
QString m_dateFormat;
+ bool m_flatMode = false;
};
diff --git a/src/types.h b/src/types.h
index d04670e..2211619 100644
--- a/src/types.h
+++ b/src/types.h
@@ -33,6 +33,18 @@ struct ThreadSummary
int matchedCount = 0;
QStringList tags;
+ /// Who the thread's messages were sent TO, summarised for one line.
+ ///
+ /// Empty unless the query asked for it, and that is a performance
+ /// contract rather than a default: To is NOT served from notmuch's index,
+ /// so filling this reads every message file. Measured at 8.7 ms per thread,
+ /// which is 38 seconds over a 4411-thread inbox and 663 ms over a
+ /// 601-thread sent view. Only a Sent query asks.
+ ///
+ /// Shown in the sender's place there, where `authors` is the user on every
+ /// row and carries nothing.
+ QString recipients;
+
bool isUnread() const { return tags.contains(QStringLiteral("unread")); }
bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); }
bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); }