diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-28 11:26:41 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-28 11:26:41 +0200 |
| commit | 9bec59c98a9f00e6e7f2c0181659a7639bc04e72 (patch) | |
| tree | b250c7e96c26626a554e3b26b020ce2ecb1fd921 | |
| parent | 0133fb982da2dcbbfacb9e89b0de79dd483b39fb (diff) | |
| download | qtmaildir-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.cpp | 23 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 8 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 79 |
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" |
