aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 11:26:41 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 11:26:41 +0200
commit9bec59c98a9f00e6e7f2c0181659a7639bc04e72 (patch)
treeb250c7e96c26626a554e3b26b020ce2ecb1fd921
parent0133fb982da2dcbbfacb9e89b0de79dd483b39fb (diff)
downloadqtmaildir-9bec59c98a9f00e6e7f2c0181659a7639bc04e72.tar.gz
qtmaildir-9bec59c98a9f00e6e7f2c0181659a7639bc04e72.zip
feat: let a row say whether it is a conversation
One predicate for the question every scope, label and membership decision in item 177 keys on. It repeats hasChildren()'s rule deliberately: an expander and a conversation are the same fact, including that a loaded thread trusts its children over a count that included duplicates.
-rw-r--r--src/threadlistmodel.cpp23
-rw-r--r--src/threadlistmodel.h8
-rw-r--r--tests/test_threadlistmodel.cpp79
3 files changed, 110 insertions, 0 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index f085b79..1bda728 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -1014,6 +1014,29 @@ bool ThreadListModel::isMessageRow(const QModelIndex &index) const
return index.isValid() && index.parent().isValid();
}
+bool ThreadListModel::isConversationRow(const QModelIndex &index) const
+{
+ if (!index.isValid() || isMessageRow(index))
+ return false;
+ if (index.row() < 0 || index.row() >= m_threads.size())
+ return false;
+
+ // A flat view has no conversations by construction: every row is one
+ // message and there is nothing to expand.
+ if (m_flatMode)
+ return false;
+
+ const ThreadNode &node = m_threads.at(index.row());
+
+ // Identical to hasChildren()'s rule, and deliberately so: an expander and a
+ // conversation are the same fact. Once loaded the children are the truth,
+ // which is how a thread whose totalCount counted DUPLICATES stops claiming
+ // to be a conversation it cannot open.
+ if (node.loaded)
+ return !node.children.isEmpty();
+ return node.summary.totalCount > 1;
+}
+
MessageNode ThreadListModel::messageAt(const QModelIndex &index) const
{
if (!isMessageRow(index))
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 4af09d8..d1baae1 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -324,6 +324,14 @@ public:
/// True when the index is a message row rather than a thread root.
bool isMessageRow(const QModelIndex &index) const;
+ /// Whether this row stands for a CONVERSATION rather than for one message.
+ ///
+ /// The single question every scope, label and membership decision keys on
+ /// (item 177). A row with replies is the conversation; a row without them
+ /// is its message and behaves as it always has. A message row is never
+ /// either, so the answer is false there rather than undefined.
+ bool isConversationRow(const QModelIndex &index) const;
+
/// The message row's node, or a default-constructed one for any index that
/// is not a message row.
MessageNode messageAt(const QModelIndex &index) const;
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 9ca35e3..df579e6 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -105,6 +105,10 @@ private slots:
void aRowCarriesItsSenderAndAccountAddress();
void aMessageRowCarriesItsOwnSenderAndAddress();
void aFlatViewsAvatarFollowsTheRecipient();
+ void aSummaryWithOneMessageIsAMessageRow();
+ void aSummaryWithRepliesIsAConversationRow();
+ void aLoadedThreadTrustsItsChildrenOverItsCount();
+ void aMessageRowIsNeverAConversationRow();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -2343,5 +2347,80 @@ void TestThreadListModel::theTrashViewDrawsNoDoomedFill()
QVERIFY(first.data(Qt::BackgroundRole).isValid());
}
+void TestThreadListModel::aSummaryWithOneMessageIsAMessageRow()
+{
+ ThreadListModel model;
+ ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone"));
+ one.totalCount = 1;
+ one.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ one });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY2(!model.isConversationRow(row),
+ "a thread of one message is not a conversation: it has no replies "
+ "to stand for, and must open its message on one click");
+}
+
+void TestThreadListModel::aSummaryWithRepliesIsAConversationRow()
+{
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ many.totalCount = 4;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY(model.isConversationRow(row));
+}
+
+void TestThreadListModel::aLoadedThreadTrustsItsChildrenOverItsCount()
+{
+ // notmuch's totalCount counts duplicates, so a "thread of 2" can load with
+ // no replies at all. Once loaded the children are the truth, exactly as
+ // hasChildren() already decides.
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Dupe"));
+ many.totalCount = 2;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t1");
+ root.depth = 0;
+ model.setThreadMessages(QStringLiteral("t1"), { root });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY2(!model.isConversationRow(row),
+ "a thread whose count came from duplicates still claims to be a "
+ "conversation after loading no replies at all");
+}
+
+void TestThreadListModel::aMessageRowIsNeverAConversationRow()
+{
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ many.totalCount = 2;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t1");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m2");
+ reply.threadId = QStringLiteral("t1");
+ reply.depth = 1;
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex thread = model.index(0, 0, QModelIndex());
+ const QModelIndex replyRow = model.index(0, 0, thread);
+ QVERIFY(model.isMessageRow(replyRow));
+ QVERIFY2(!model.isConversationRow(replyRow),
+ "a reply row answered yes, so an action on it would scope to the "
+ "whole conversation");
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"