aboutsummaryrefslogtreecommitdiffstats
path: root/src/threadlistmodel.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 13:28:51 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 13:30:22 +0200
commit0ca4624195cdd8c78ff614e3912af5b914458497 (patch)
tree6fc739e77fed6b5a4e178cacf76ac29fd708ab0b /src/threadlistmodel.cpp
parentd835cf3c554e9657fffdb91971b66e3a74aee323 (diff)
downloadqtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.tar.gz
qtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.zip
feat: flag what you answered, mark what was forwarded to you
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" subject rule to "Fw:"; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
Diffstat (limited to 'src/threadlistmodel.cpp')
-rw-r--r--src/threadlistmodel.cpp95
1 files changed, 92 insertions, 3 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 6162a5f..fcd8e4f 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -18,6 +18,8 @@
#include "threadlistmodel.h"
+#include "composecontext.h"
+
#include <QSet>
#include <QBrush>
@@ -177,6 +179,65 @@ void ThreadListModel::setFlatMode(bool flat)
endResetModel();
}
+void ThreadListModel::setTrashView(bool trash)
+{
+ if (m_trashView == trash)
+ return;
+
+ m_trashView = trash;
+
+ // A repaint, NOT a reset: this changes two colour roles and nothing about
+ // the shape of the tree, so unlike setFlatMode() there are no child rows
+ // to invalidate and a reset would collapse every expanded thread for a
+ // change of paint. Emitted over the whole list including children, since
+ // the message-row branch reads the same flag.
+ if (m_threads.isEmpty())
+ return;
+ const QVector<int> roles{ Qt::BackgroundRole, Qt::ForegroundRole };
+ emit dataChanged(index(0, 0, QModelIndex()),
+ index(m_threads.size() - 1, 0, QModelIndex()), roles);
+ for (int row = 0; row < m_threads.size(); ++row) {
+ const QModelIndex parent = index(row, 0, QModelIndex());
+ const int children = rowCount(parent);
+ if (children > 0) {
+ emit dataChanged(index(0, 0, parent),
+ index(children - 1, 0, parent), roles);
+ }
+ }
+}
+
+void ThreadListModel::removeThreadsWithoutTag(const QString &tag)
+{
+ if (tag.isEmpty() || m_threads.isEmpty())
+ return;
+
+ // The tags a row is judged on are the ones its CARD draws: the loaded
+ // message's own when there is one, the thread's union otherwise. That is
+ // the same substitution data() makes for a thread row, and using the
+ // summary alone would keep a row whose displayed message lost the tag
+ // while a sibling still carries it.
+ const auto keeps = [&tag](const ThreadNode &node) {
+ if (!node.first.messageId.isEmpty())
+ return node.first.tags.contains(tag);
+ return node.summary.tags.contains(tag);
+ };
+
+ // Backwards, in contiguous runs, exactly as reconcile() removes: each
+ // beginRemoveRows renumbers everything after it, so walking forwards
+ // removes the wrong rows after the first deletion.
+ for (int row = m_threads.size() - 1; row >= 0; --row) {
+ if (keeps(m_threads.at(row)))
+ continue;
+ int first = row;
+ while (first > 0 && !keeps(m_threads.at(first - 1)))
+ --first;
+ beginRemoveRows({}, first, row);
+ m_threads.remove(first, row - first + 1);
+ endRemoveRows();
+ row = first;
+ }
+}
+
int ThreadListModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid())
@@ -338,6 +399,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return node.isFlagged();
case IsPassedRole:
return node.isPassed();
+ case IsReceivedForwardRole:
+ return ComposeContextBuilder::subjectIsForwarded(node.subject,
+ m_forwardPrefixes);
case IsRepliedRole:
return node.isReplied();
case ReplyCountRole:
@@ -352,9 +416,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// thread row does. Without this branch a message-scoped Delete
// repainted a reply identically to an undeleted one, so the
// pending count moved and nothing on screen did.
- if (node.isDoomed())
+ // Suppressed in the trash view, exactly as on a thread row: see
+ // the comment there. Both branches must agree, or an expanded
+ // thread in the trash paints its replies crimson under an
+ // untinted root.
+ if (node.isDoomed()
+ && !(m_trashView && node.isDeleted() && !node.isSpam())) {
return QBrush(node.isDeleted() ? deletedColour()
: spamColour());
+ }
// Tinted, so an expanded thread reads as one block rather than as
// more table rows. Applied per cell here; ThreadListView fills the
@@ -401,8 +471,16 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// read colour is mixed toward the BACKGROUND, so leaving it here
// would compute a grey against the pane's base and then paint it
// over red.
- if (node.isDoomed())
+ //
+ // Tied to the FILL, not to isDoomed(): where the fill is
+ // suppressed in the trash view there is no red to sit on, and
+ // white text would land on the ordinary background unreadable.
+ // The strike-out below is deliberately NOT suppressed, since it
+ // is the cue that survives without colour at all.
+ if (node.isDoomed()
+ && !(m_trashView && node.isDeleted() && !node.isSpam())) {
return QBrush(QColor(Qt::white));
+ }
// Dimmed whether read or not, for the same reason as the font: a
// reply is subordinate content. An unread one is left undimmed so
@@ -611,6 +689,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return thread.isFlagged();
case IsPassedRole:
return thread.isPassed();
+ case IsReceivedForwardRole:
+ return ComposeContextBuilder::subjectIsForwarded(thread.subject,
+ m_forwardPrefixes);
case IsRepliedRole:
return thread.isReplied();
case ReplyCountRole:
@@ -633,7 +714,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// whole row: a cue on a single column disappears as soon as that column
// scrolls out of view, which is exactly how the tag change used to go
// unnoticed.
- if (thread.isDoomed()) {
+ //
+ // In the TRASH view the deleted fill is suppressed: every row there is
+ // deleted, so a list painted entirely crimson tells the user nothing they
+ // did not ask for by opening the trash, and costs the legibility the fill
+ // borrows. Only `deleted` is suppressed; a SPAM row keeps its tint, since
+ // "this is junk" is still news in a folder that only promises "this is
+ // thrown away".
+ const bool suppressed = m_trashView && thread.isDeleted() && !thread.isSpam();
+ if (thread.isDoomed() && !suppressed) {
if (role == Qt::BackgroundRole)
return QBrush(thread.isDeleted() ? deletedColour() : spamColour());
if (role == Qt::ForegroundRole)