From 44d62143a83af8acbd1c1d14653d39da37e5de4a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 11 Aug 2026 12:41:14 +0200 Subject: feat(sent): add a Sent view, flat and by recipient Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" , info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63. --- src/config.cpp | 37 +++++++++++++++++++++++++++++++ src/config.h | 31 ++++++++++++++++++++++++++ src/mainwindow.cpp | 50 +++++++++++++++++++++++++++++++++++++---- src/mainwindow.h | 28 ++++++++++++++++++++++- src/mimeparser.cpp | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ src/mimeparser.h | 17 ++++++++++++++ src/notmuchworker.cpp | 57 +++++++++++++++++++++++++++++++++++++++++++++-- src/notmuchworker.h | 15 +++++++++++-- src/threadlistmodel.cpp | 39 ++++++++++++++++++++++++++++++++ src/threadlistmodel.h | 19 ++++++++++++++++ src/types.h | 12 ++++++++++ 11 files changed, 355 insertions(+), 9 deletions(-) (limited to 'src') 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 /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 &threads, @@ -1863,10 +1895,16 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, 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 &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" , 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 +#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(id), 0, static_cast(-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 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")); } -- cgit v1.2.3