diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-26 13:28:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-26 13:30:22 +0200 |
| commit | 0ca4624195cdd8c78ff614e3912af5b914458497 (patch) | |
| tree | 6fc739e77fed6b5a4e178cacf76ac29fd708ab0b /src/threadlistmodel.cpp | |
| parent | d835cf3c554e9657fffdb91971b66e3a74aee323 (diff) | |
| download | qtmaildir-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.cpp | 95 |
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) |
