summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 08:28:22 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 08:28:22 +0200
commit2d062241732f7396e8cb12295962d3f3e5c1a2b0 (patch)
tree6dfb0655d5b32d4457bd5bf508a844da7b57736a
parent1335d3c7f891a22f61307d2d186483f56e8e558c (diff)
downloadqtmaildir-2d062241732f7396e8cb12295962d3f3e5c1a2b0.tar.gz
qtmaildir-2d062241732f7396e8cb12295962d3f3e5c1a2b0.zip
feat(model): expose the tags a reply has and its thread does not
A reply card shows only these. The alternative, a reply's full tag set, was rejected on measurement rather than taste: in the user's database 7 of 48691 messages carry unread and 75 carry flagged, both already drawn another way, and every other tag is applied per thread and identical on all its messages. Full sets would repeat the thread's chips down the whole expansion, which is the striping the row-wide strip was built to avoid.
-rw-r--r--src/threadlistmodel.cpp37
-rw-r--r--src/threadlistmodel.h20
-rw-r--r--tests/test_threadlistmodel.cpp69
3 files changed, 126 insertions, 0 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 9a74041..0956e6b 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -268,6 +268,37 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return QStringList();
case PillColoursRole:
return QVariantList();
+ case MessageOwnTagsRole: {
+ // Set difference against the parent THREAD, not against a global
+ // list: "own" means "not already said by the card above this one".
+ // The parent row indexes m_threads directly, which is the same
+ // mapping messageAt() uses to reach this node.
+ const int threadRow = index.parent().row();
+ const QStringList threadTags =
+ (threadRow >= 0 && threadRow < m_threads.size())
+ ? m_threads.at(threadRow).summary.tags
+ : QStringList();
+ QStringList own;
+ for (const QString &tag : node.tags) {
+ if (!threadTags.contains(tag))
+ own.append(tag);
+ }
+ // Sorted, so a reply does not reshuffle its own chips between
+ // repaints, matching what PillTagsRole already guarantees.
+ own.sort();
+ return own;
+ }
+ case MessageOwnColoursRole: {
+ const QStringList own =
+ data(index, MessageOwnTagsRole).toStringList();
+ QVariantList colours;
+ colours.reserve(own.size());
+ for (const QString &tag : own) {
+ colours.append(m_tagColors ? m_tagColors->colourFor(tag)
+ : TagColors().colourFor(tag));
+ }
+ return colours;
+ }
case AccountLabelRole:
return QString();
case Qt::DisplayRole:
@@ -344,6 +375,12 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == TagsRole)
return thread.tags;
+ if (role == MessageOwnTagsRole)
+ return QStringList();
+
+ if (role == MessageOwnColoursRole)
+ return QVariantList();
+
if (role == PillTagsRole || role == PillColoursRole) {
// Everything the row already says another way is dropped: the account
// is the chip in the subject cell, flagged is the star column,
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index b80488b..b13165c 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -103,6 +103,26 @@ public:
/// delegate cannot call hasChildren without the model, and the same
/// answer has to reach the cell that reserves room for the glyph.
HasRepliesRole,
+
+ /// The tags this MESSAGE carries that its thread does not.
+ ///
+ /// A reply card shows these and nothing else. Showing a reply's full
+ /// tag set instead was measured against the user's own database and
+ /// rejected: of 48691 messages, 7 carry `unread` and 75 carry
+ /// `flagged`, and both are already drawn another way (the sender's
+ /// weight, and the mark on line 2). Every other tag is applied to a
+ /// whole thread and is identical on all its messages, so full sets
+ /// would repeat the thread's own chips down the entire expansion,
+ /// which is the striping the old row-wide strip existed to avoid.
+ ///
+ /// Empty on a thread row, which has no thread to differ from.
+ MessageOwnTagsRole,
+
+ /// The colours for MessageOwnTagsRole, in the same order. Supplied by
+ /// the model for the same reason as PillColoursRole: it owns the
+ /// TagColors instance, and a delegate reading config itself would be a
+ /// second source of truth.
+ MessageOwnColoursRole,
};
/// Row fill for a thread tagged `deleted`, and for one tagged `spam`.
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 3d131cf..4df0269 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -31,6 +31,8 @@ private slots:
void rootRowsSurviveTheTreeConversion();
void repliesBecomeChildRowsUnderTheirThread();
void messageRowsShowTheirOwnSenderAndSubject();
+ void replyShowsOnlyItsOwnTags();
+ void replySharingEveryThreadTagShowsNone();
void reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem();
void anUnexpandedMultiMessageThreadOffersAnExpander();
void scopeFollowsTheSelectedRowKind();
@@ -1044,5 +1046,72 @@ void TestThreadListModel::attachmentColumnIsFirstAndMarksOnlyTaggedThreads()
Qt::DisplayRole).toString().isEmpty());
}
+void TestThreadListModel::replyShowsOnlyItsOwnTags()
+{
+ ThreadListModel model;
+ ThreadSummary thread;
+ thread.threadId = QStringLiteral("T1");
+ thread.subject = QStringLiteral("Build fails");
+ thread.totalCount = 2;
+ thread.tags = { QStringLiteral("inbox"), QStringLiteral("work") };
+ model.appendBatch({ thread });
+
+ MessageNode reply;
+ reply.messageId = QStringLiteral("M2");
+ reply.threadId = QStringLiteral("T1");
+ reply.from = QStringLiteral("bob@example.org");
+ reply.depth = 1;
+ // Two the thread already has, one it does not.
+ reply.tags = { QStringLiteral("inbox"), QStringLiteral("work"),
+ QStringLiteral("todo") };
+ model.setThreadMessages(QStringLiteral("T1"), { reply });
+
+ const QModelIndex threadIndex = model.index(0, 0);
+ QVERIFY(model.hasChildren(threadIndex));
+ const QModelIndex replyIndex = model.index(0, 0, threadIndex);
+ QVERIFY(replyIndex.isValid());
+
+ const QStringList own =
+ replyIndex.data(ThreadListModel::MessageOwnTagsRole).toStringList();
+ QCOMPARE(own, QStringList{ QStringLiteral("todo") });
+
+ // The colours must line up with the names one for one, or the delegate
+ // walks the two lists together and paints a chip in another tag's colour.
+ const QVariantList colours =
+ replyIndex.data(ThreadListModel::MessageOwnColoursRole).toList();
+ QCOMPARE(colours.size(), own.size());
+ QVERIFY(colours.first().value<QColor>().isValid());
+}
+
+void TestThreadListModel::replySharingEveryThreadTagShowsNone()
+{
+ ThreadListModel model;
+ ThreadSummary thread;
+ thread.threadId = QStringLiteral("T1");
+ thread.totalCount = 2;
+ thread.tags = { QStringLiteral("inbox"), QStringLiteral("work") };
+ model.appendBatch({ thread });
+
+ MessageNode reply;
+ reply.messageId = QStringLiteral("M2");
+ reply.threadId = QStringLiteral("T1");
+ reply.depth = 1;
+ reply.tags = { QStringLiteral("inbox"), QStringLiteral("work") };
+ model.setThreadMessages(QStringLiteral("T1"), { reply });
+
+ const QModelIndex replyIndex = model.index(0, 0, model.index(0, 0));
+ QVERIFY(replyIndex.isValid());
+ QVERIFY(replyIndex.data(ThreadListModel::MessageOwnTagsRole)
+ .toStringList()
+ .isEmpty());
+
+ // A thread row has no thread to differ from, so it never answers these:
+ // its own chips come from PillTagsRole.
+ QVERIFY(model.index(0, 0)
+ .data(ThreadListModel::MessageOwnTagsRole)
+ .toStringList()
+ .isEmpty());
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"