summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/carddelegate.cpp53
-rw-r--r--src/cardlayout.cpp58
-rw-r--r--src/cardlayout.h31
-rw-r--r--src/mainwindow.cpp23
-rw-r--r--src/threadlistmodel.cpp37
-rw-r--r--src/threadlistmodel.h9
-rw-r--r--tests/test_cardlayout.cpp75
-rw-r--r--tests/test_mainwindow.cpp39
-rw-r--r--tests/test_threadlistmodel.cpp93
9 files changed, 388 insertions, 30 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index a3a846d..f13205e 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -157,7 +157,7 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QDateTime date =
index.data(ThreadListModel::DateRole).toDateTime();
painter->drawText(card.dateRect, Qt::AlignVCenter | Qt::AlignRight,
- date.toString(QStringLiteral("yyyy-MM-dd hh:mm")));
+ CardLayout::formatDate(date));
// Line 2: the flag mark, the subject, the attachment mark.
QString subject = index.data(ThreadListModel::SubjectRole).toString();
@@ -178,16 +178,51 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
metrics.elidedText(line2, Qt::ElideRight,
card.subjectRect.width()));
- // The reply count, which is also the expander.
+ // The reply count, which is also the expander, drawn as a PILL.
+ //
+ // A bare "3" on the card's own background read as an unexplained number
+ // beside the subject and gave no hint that it could be clicked. The chip
+ // shape says "this is a control", matching the tag chips on line 3, and the
+ // word says what the number counts.
if (!card.expanderRect.isEmpty()) {
- painter->setFont(CardLayout::smallFont(chrome.font));
const int count = index.data(ThreadListModel::ReplyCountRole).toInt();
- const QString glyph = (option.state & QStyle::State_Open)
- ? QStringLiteral("▾")
- : QStringLiteral("▸");
- painter->drawText(card.expanderRect, Qt::AlignVCenter | Qt::AlignRight,
- QStringLiteral("%1 %2").arg(glyph).arg(count));
- painter->setFont(chrome.font);
+ const QString label = CardLayout::expanderLabel(
+ count, option.state & QStyle::State_Open);
+
+ painter->save();
+ painter->setFont(CardLayout::smallFont(chrome.font));
+
+ // Blended from Text toward Base rather than taken from a palette ROLE.
+ // QPalette::Button is the role this obviously wants and it is
+ // #2b2b2b against a Base of #2b2b2b on the user's theme: byte
+ // identical, so the pill was invisible. A theme is free to make any two
+ // roles equal, and several do; a blend cannot collide with the surface
+ // it sits on because it is defined relative to it.
+ //
+ // Toward Text, so it darkens on a light theme and lightens on a dark
+ // one, the same trick replyBackground() and threadLineColour() use.
+ const QColor base = option.palette.color(QPalette::Base);
+ const QColor text = option.palette.color(QPalette::Text);
+ constexpr qreal kFillWeight = 0.18;
+ const QColor fill = QColor::fromRgbF(
+ text.redF() * kFillWeight + base.redF() * (1.0 - kFillWeight),
+ text.greenF() * kFillWeight + base.greenF() * (1.0 - kFillWeight),
+ text.blueF() * kFillWeight + base.blueF() * (1.0 - kFillWeight));
+ painter->setRenderHint(QPainter::Antialiasing, true);
+ painter->setPen(Qt::NoPen);
+ painter->setBrush(fill);
+ // Fully rounded ends, the same shape TagChip paints: the radius is half
+ // the height, so the pill cannot look like a rectangle with soft corners.
+ const qreal radius = card.expanderRect.height() / 2.0;
+ painter->drawRoundedRect(card.expanderRect, radius, radius);
+
+ // The pen is restored from the card's own text colour rather than
+ // ButtonText, which belongs to the role that just proved unreliable.
+ painter->setPen(option.state & QStyle::State_Selected
+ ? option.palette.highlightedText().color()
+ : text);
+ painter->drawText(card.expanderRect, Qt::AlignCenter, label);
+ painter->restore();
}
painter->restore();
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
index 1a79e3b..0e118ab 100644
--- a/src/cardlayout.cpp
+++ b/src/cardlayout.cpp
@@ -19,6 +19,44 @@
#include "cardlayout.h"
#include <QFontMetrics>
+#include <QLocale>
+
+QString CardLayout::formatDate(const QDateTime &date)
+{
+ // The system locale's own short format, not a hardcoded pattern: an
+ // Italian desktop writes 10/08/2025, not 2025-08-10, and a mail client
+ // that disagrees with every other application on screen is simply wrong.
+ return QLocale::system().toString(date, QLocale::ShortFormat);
+}
+
+QString CardLayout::expanderLabel(int replyCount, bool expanded)
+{
+ // "3 replies", not a bare "3". The count alone reads as an unexplained
+ // number beside the subject, and the word is what says the card opens.
+ //
+ // Not translated through tr() here because CardLayout is a plain struct
+ // rather than a QObject; the delegate is where a translated build would
+ // wrap this, and the string is deliberately kept in one place so there is
+ // exactly one thing to change.
+ const QString glyph = expanded ? QStringLiteral("\u25be")
+ : QStringLiteral("\u25b8");
+ const QString word = replyCount == 1 ? QStringLiteral("reply")
+ : QStringLiteral("replies");
+ return QStringLiteral("%1 %2 %3").arg(glyph).arg(replyCount).arg(word);
+}
+
+QString CardLayout::widestDateSample()
+{
+ // A real date run through the same formatter, with the wide digits and a
+ // two-digit day and month, so the reserved width matches what is drawn
+ // whatever the locale's pattern turns out to be. Guessing a pattern here
+ // would reintroduce the clipping this exists to prevent.
+ static const QString sample = [] {
+ const QDateTime wide(QDate(2028, 12, 28), QTime(22, 58));
+ return formatDate(wide);
+ }();
+ return sample;
+}
QFont CardLayout::smallFont(const QFont &cardFont)
{
@@ -96,8 +134,8 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
// painted.
QFont dateFont = font;
dateFont.setBold(true);
- const int dateWidth = QFontMetrics(dateFont).horizontalAdvance(
- QStringLiteral("8888-88-88 88:88"));
+ const int dateWidth =
+ QFontMetrics(dateFont).horizontalAdvance(widestDateSample());
out.dateRect = QRect(right - dateWidth, lineOneTop, dateWidth,
metrics.height());
out.senderRect = QRect(out.contentLeft, lineOneTop,
@@ -105,10 +143,20 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
- kPaddingX),
metrics.height());
- // The expander is the reply count, on line two and on the right.
+ // The expander is the reply count as a PILL, on line two and on the right.
+ //
+ // Sized from the label actually drawn rather than from a fixed sample, so
+ // the background and the text inside it cannot disagree. Both states of the
+ // glyph are measured because the rect must not change width when the card
+ // is expanded: a pill that resized on click would shift the subject's
+ // elision under the pointer.
if (input.replyCount > 0) {
- const int countWidth = smallMetrics.horizontalAdvance(
- QStringLiteral("▾ 8888 replies"));
+ const int collapsed = smallMetrics.horizontalAdvance(
+ expanderLabel(input.replyCount, false));
+ const int expanded = smallMetrics.horizontalAdvance(
+ expanderLabel(input.replyCount, true));
+ const int countWidth =
+ qMax(collapsed, expanded) + kPillPaddingX * 2;
out.expanderRect = QRect(right - countWidth, lineTwoTop, countWidth,
metrics.height());
}
diff --git a/src/cardlayout.h b/src/cardlayout.h
index d06ed92..ef6a563 100644
--- a/src/cardlayout.h
+++ b/src/cardlayout.h
@@ -18,6 +18,7 @@
#pragma once
+#include <QDateTime>
#include <QFont>
#include <QRect>
#include <QVector>
@@ -116,4 +117,34 @@ struct CardLayout
static CardLayout compute(const Input &input, const QRect &rect,
const QFont &font);
+
+ /// How a card writes a date, in the user's own locale.
+ ///
+ /// Never a hardcoded pattern. "yyyy-MM-dd hh:mm" is a US-looking format
+ /// that an Italian desktop does not use, and the whole point of asking the
+ /// system locale is that the user reads dates the way their desktop writes
+ /// them everywhere else.
+ ///
+ /// Shared with the layout so the width reserved for the date and the text
+ /// drawn into it come from one place: a locale whose short format is
+ /// longer than the reserved rect would clip, which is exactly the fault
+ /// bold text produced.
+ static QString formatDate(const QDateTime &date);
+
+ /// The widest string formatDate() can return, for reserving space.
+ static QString widestDateSample();
+
+ /// The expander's label: the reply count with its glyph, as drawn.
+ ///
+ /// Shared with the layout for the same reason as formatDate: the rect
+ /// reserved for the pill and the text put inside it must come from one
+ /// place, or a count wider than the sample the layout guessed at spills
+ /// out of its own background.
+ ///
+ /// `expanded` chooses which way the triangle points.
+ static QString expanderLabel(int replyCount, bool expanded);
+
+ /// Padding inside the expander pill, matching a tag chip's, so the two read
+ /// as the same kind of object on the card.
+ static constexpr int kPillPaddingX = 8;
};
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 5881fae..b7efec5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1755,10 +1755,31 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
}
const ThreadSummary thread = m_model->threadAt(current.row());
- m_currentMessageId.clear();
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
scheduleMarkRead(thread);
+
+ // The root card IS the thread's first message, so selecting it renders
+ // that message rather than the whole conversation. Loading the thread here
+ // made the first message unreachable: the pane showed every message with
+ // only the last expanded, and no row in the list offered the first one,
+ // since the reply rows are messages two onward.
+ //
+ // Known only once the replies have been loaded, which happens when the
+ // thread is expanded. Until then the thread is the honest answer: it
+ // contains the first message, where a guess might not.
+ const QString firstId =
+ m_model->data(current, ThreadListModel::MessageIdRole).toString();
+ if (!firstId.isEmpty()) {
+ m_currentMessageId = firstId;
+ QMetaObject::invokeMethod(m_worker, "loadMessage",
+ Qt::QueuedConnection,
+ Q_ARG(QString, firstId),
+ Q_ARG(quint64, m_generation));
+ return;
+ }
+
+ m_currentMessageId.clear();
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index d8412f9..bdc7e96 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -365,8 +365,13 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == IsMessageRole)
return false;
- if (role == MessageIdRole)
- return QString();
+ if (role == MessageIdRole) {
+ // The thread's FIRST message, once known, because the root card is
+ // that message: selecting it renders one message rather than the whole
+ // conversation. Empty before the replies are loaded, which is the
+ // caller's signal to load the thread instead of guessing at a message.
+ return m_threads.at(index.row()).first.messageId;
+ }
if (role == MessageDepthRole)
return 0;
@@ -542,7 +547,7 @@ void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
const int first = m_threads.size();
beginInsertRows({}, first, first + batch.size() - 1);
for (const ThreadSummary &summary : batch)
- m_threads.append(ThreadNode{ summary, {}, false });
+ m_threads.append(ThreadNode{ summary, {}, {}, false });
endInsertRows();
}
@@ -570,12 +575,26 @@ void ThreadListModel::setThreadMessages(const QString &threadId,
endRemoveRows();
}
- QVector<MessageNode> children;
- children.reserve(nodes.size());
- for (const MessageNode &node : nodes) {
- if (node.depth > 0)
- children.append(node);
- }
+ // Every message EXCEPT the first, which is the root card itself.
+ //
+ // Selecting on depth > 0 instead was wrong, and wrong in a way that
+ // only showed on real mail: notmuch_thread_get_toplevel_messages
+ // returns every message at depth 0 when a thread carries no usable
+ // In-Reply-To, so a flat thread contributed no children at all. The
+ // card advertised "3 replies" and expanded onto nothing. Measured in
+ // the user's database: of 396 inbox threads, three are flat, one of
+ // them nine messages long, and every two-message thread of this kind
+ // was affected, which is why the fault looked like "the expander only
+ // works with more than one reply".
+ //
+ // Position also happens to be the right rule rather than a workaround.
+ // The root card IS the thread's first message, so the row under it is
+ // the second message whatever depth notmuch assigns it.
+ QVector<MessageNode> children = nodes.mid(1);
+
+ // Kept so the root card can render its own message. It is the card the
+ // user clicks to read the thread's opening message.
+ m_threads[row].first = nodes.isEmpty() ? MessageNode() : nodes.first();
if (!children.isEmpty()) {
beginInsertRows(parent, 0, children.size() - 1);
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 9cb774e..f381ffa 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -240,6 +240,15 @@ private:
ThreadSummary summary;
QVector<MessageNode> children; ///< Empty until the thread is expanded.
+ /// The thread's FIRST message, which the root card itself draws.
+ ///
+ /// Kept because the root card is that message: selecting it must
+ /// render one message rather than the whole conversation, and without
+ /// this the first message of every thread is unreachable, since the
+ /// only rows offering a message are the replies and it is not one of
+ /// them. Empty until the replies are loaded.
+ MessageNode first;
+
/// Distinguishes "this thread has no replies" from "its replies have
/// not been asked for yet". Without it an expander would be drawn over
/// every thread, including the ones that turn out to be single
diff --git a/tests/test_cardlayout.cpp b/tests/test_cardlayout.cpp
index c38f728..48bba25 100644
--- a/tests/test_cardlayout.cpp
+++ b/tests/test_cardlayout.cpp
@@ -19,6 +19,7 @@
#include "cardlayout.h"
#include <QFont>
+#include <QLocale>
#include <QTest>
class TestCardLayout : public QObject
@@ -32,10 +33,12 @@ private slots:
void indentStopsAtTheCap();
void expanderSitsOnTheSecondLine();
void expanderIsEmptyWithoutReplies();
+ void theExpanderReadsAsAPillWithAWord();
void dateIsFlushRight();
void threadCardCarriesAnAccentBar();
void replyCardCarriesNoAccentBar();
void theDateFitsWhenTheCardIsBold();
+ void theDateFollowsTheSystemLocale();
};
namespace {
@@ -183,6 +186,42 @@ void TestCardLayout::expanderIsEmptyWithoutReplies()
QVERIFY(card.expanderRect.isEmpty());
}
+void TestCardLayout::theExpanderReadsAsAPillWithAWord()
+{
+ // A bare "3" beside the subject reads as an unexplained number and gives
+ // no hint that it can be clicked. The label carries the word, and the rect
+ // carries padding for the pill drawn behind it.
+ QCOMPARE(CardLayout::expanderLabel(3, false),
+ QStringLiteral("\u25b8 3 replies"));
+ QCOMPARE(CardLayout::expanderLabel(3, true),
+ QStringLiteral("\u25be 3 replies"));
+
+ // Singular, because "1 replies" is the kind of detail that makes an
+ // interface look unfinished.
+ QCOMPARE(CardLayout::expanderLabel(1, false),
+ QStringLiteral("\u25b8 1 reply"));
+
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+ const QFontMetrics small(CardLayout::smallFont(font));
+
+ // The rect must hold the label AND its padding, or the pill's background
+ // is narrower than the text sitting on it.
+ QVERIFY2(card.expanderRect.width()
+ >= small.horizontalAdvance(CardLayout::expanderLabel(3, false))
+ + CardLayout::kPillPaddingX * 2,
+ "the expander rect is too narrow for its own label and padding");
+
+ // And it must NOT change width when the card opens: a pill that resized on
+ // click would shift the subject's elision under the pointer.
+ CardLayout::Input open = threadInput();
+ const CardLayout expanded =
+ CardLayout::compute(open, QRect(0, 0, 400, h), font);
+ QCOMPARE(expanded.expanderRect.width(), card.expanderRect.width());
+}
+
void TestCardLayout::dateIsFlushRight()
{
const QFont font;
@@ -232,6 +271,42 @@ void TestCardLayout::replyCardCarriesNoAccentBar()
QCOMPARE(reply.spines.size(), 1);
}
+void TestCardLayout::theDateFollowsTheSystemLocale()
+{
+ const QDateTime when(QDate(2025, 8, 10), QTime(6, 26));
+
+ // The system locale's own rendering, whatever it is. Asserting a specific
+ // string would only restate the hardcoded pattern this replaced, and would
+ // fail on any machine but the one that wrote it.
+ QCOMPARE(CardLayout::formatDate(when),
+ QLocale::system().toString(when, QLocale::ShortFormat));
+
+ // The specific fault: an ISO-looking pattern on a desktop that does not
+ // use one. Guarded so this test says nothing on a locale that genuinely
+ // formats that way.
+ if (QLocale::system().toString(when, QLocale::ShortFormat)
+ != QStringLiteral("2025-08-10 06:26")) {
+ QVERIFY2(CardLayout::formatDate(when)
+ != QStringLiteral("2025-08-10 06:26"),
+ "the date is hardcoded to yyyy-MM-dd hh:mm rather than "
+ "following the desktop's locale");
+ }
+
+ // And the reserved width has to follow the same formatter, or a locale
+ // whose dates are longer clips them exactly as the bold font did.
+ QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout card =
+ CardLayout::compute(threadInput(), QRect(0, 0, 400, h), font);
+ QFont bold = font;
+ bold.setBold(true);
+ QVERIFY2(card.dateRect.width()
+ >= QFontMetrics(bold).horizontalAdvance(
+ CardLayout::formatDate(when)),
+ "the reserved date width is narrower than this locale's own "
+ "formatting of a date");
+}
+
void TestCardLayout::theDateFitsWhenTheCardIsBold()
{
// An UNREAD card draws BOLD, and bold is wider. The layout is computed from
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index cdb08ca..922705c 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -105,6 +105,7 @@ private slots:
void childRowsAreIndentedUnderTheirThread();
void aThreadWithRepliesDrawsAVisibleExpander();
void cardsNeverScrollSideways();
+ void selectingARootCardKeepsItsThreadForMarkRead();
void nextThreadLeavesTheLastReply();
void altDownSkipsReplies();
void bothThreadStepBindingsReachTheAction();
@@ -667,8 +668,12 @@ NavFixture buildNavFixture(MainWindow &window)
f.view = window.findChild<QTreeView *>();
f.model = window.findChild<ThreadListModel *>();
+ // unread, so a selection arms the mark-read timer: scheduleMarkRead()
+ // returns early for a thread that is already read, and a fixture without
+ // it would make a mark-read assertion pass for the wrong reason.
ThreadSummary first = makeThread(QStringLiteral("T1"),
- QStringList{ QStringLiteral("inbox") });
+ QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("unread") });
first.totalCount = 2;
ThreadSummary second = makeThread(QStringLiteral("T2"),
QStringList{ QStringLiteral("inbox") });
@@ -693,6 +698,38 @@ NavFixture buildNavFixture(MainWindow &window)
} // namespace
+void TestMainWindow::selectingARootCardKeepsItsThreadForMarkRead()
+{
+ // A root card is BOTH a message and a thread: it renders the thread's
+ // first message, and it is still the thread that gets marked read and
+ // repainted on a tag change. The message-row path deliberately clears the
+ // current thread id; doing that here too would silently disable mark-read
+ // and the tag-change repaint for every thread root in the list.
+ const Config config;
+ MainWindow window(config);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ const NavFixture f = buildNavFixture(window);
+ f.view->setCurrentIndex(f.root);
+ QApplication::processEvents();
+
+ // Guard: the fixture loads replies, so the root knows its own message and
+ // the branch under test is the one that runs.
+ QVERIFY2(!f.model->data(f.root, ThreadListModel::MessageIdRole)
+ .toString().isEmpty(),
+ "the root card does not know its first message, so this exercises "
+ "the fallback rather than the path it is written for");
+
+ // A mark-read timer armed for the thread is what proves the thread id
+ // survived: scheduleMarkRead() is only reached on the thread-row path.
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(),
+ "no mark-read timer for a selected root card: its thread id was "
+ "cleared along with the switch to rendering one message");
+}
+
void TestMainWindow::nextThreadLeavesTheLastReply()
{
const Config config;
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 49b8894..aa71080 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -32,6 +32,8 @@ private slots:
void repliesBecomeChildRowsUnderTheirThread();
void messageRowsShowTheirOwnSenderAndSubject();
void modelHasOneColumn();
+ void aFlatThreadStillListsItsReplies();
+ void theRootCardKnowsItsOwnMessage();
void replyShowsOnlyItsOwnTags();
void replySharingEveryThreadTagShowsNone();
void reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem();
@@ -131,10 +133,13 @@ void TestThreadListModel::repliesBecomeChildRowsUnderTheirThread()
QCOMPARE(model.data(child, ThreadListModel::ThreadIdRole).toString(),
QStringLiteral("t1"));
- // A thread root is not a message row and carries no message id.
+ // A thread root is not a message ROW, but it does carry a message id: the
+ // root card is the thread's first message, and selecting it renders that
+ // message alone. It used to answer nothing here, which is what made the
+ // first message of every thread unreachable.
QVERIFY(!model.data(root, ThreadListModel::IsMessageRole).toBool());
- QVERIFY(model.data(root, ThreadListModel::MessageIdRole)
- .toString().isEmpty());
+ QCOMPARE(model.data(root, ThreadListModel::MessageIdRole).toString(),
+ QStringLiteral("m0@example.org"));
QAbstractItemModelTester tester(
&model, QAbstractItemModelTester::FailureReportingMode::Warning);
@@ -1022,6 +1027,72 @@ void TestThreadListModel::modelHasOneColumn()
QCOMPARE(index.data(ThreadListModel::ReplyCountRole).toInt(), 0);
}
+void TestThreadListModel::aFlatThreadStillListsItsReplies()
+{
+ // A thread whose messages carry no reply structure: notmuch returns them
+ // all from get_toplevel_messages at depth 0, which is what happens when the
+ // mail has no usable In-Reply-To. Measured in the user's own database:
+ // of 396 inbox threads, three are like this, one of them nine messages
+ // deep, and every one of them showed a reply count that expanded to
+ // nothing because the model kept only nodes with depth > 0.
+ ThreadListModel model;
+ ThreadSummary thread = makeThread(QStringLiteral("t1"),
+ QStringLiteral("flat thread"));
+ thread.totalCount = 3;
+ model.appendBatch({ thread });
+
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 0),
+ makeNode(QStringLiteral("m2@example.org"), 0) });
+
+ const QModelIndex root = model.index(0, 0);
+
+ // Two children, not zero: the FIRST message is the root card itself, and
+ // the rest are its replies however flat the thread is.
+ QCOMPARE(model.rowCount(root), 2);
+ QCOMPARE(model.index(0, 0, root).data(ThreadListModel::MessageIdRole)
+ .toString(),
+ QStringLiteral("m1@example.org"));
+
+ // And the count the card advertises must agree with the rows beneath it,
+ // or the expander opens onto nothing.
+ QCOMPARE(root.data(ThreadListModel::ReplyCountRole).toInt(),
+ model.rowCount(root));
+}
+
+void TestThreadListModel::theRootCardKnowsItsOwnMessage()
+{
+ // The root card IS the thread's first message, so it has to be able to say
+ // which message that is. Without this the pane renders the whole thread
+ // when the root is selected, and the first message is unreachable: the
+ // only rows offering it are the replies, and it is not one of them.
+ ThreadListModel model;
+ ThreadSummary thread = makeThread(QStringLiteral("t1"),
+ QStringLiteral("a subject"));
+ thread.totalCount = 2;
+ model.appendBatch({ thread });
+
+ const QModelIndex root = model.index(0, 0);
+
+ // Before the replies are loaded there is nothing to report, and the caller
+ // must fall back to loading the whole thread rather than a wrong message.
+ QVERIFY(root.data(ThreadListModel::MessageIdRole).toString().isEmpty());
+
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+
+ QCOMPARE(root.data(ThreadListModel::MessageIdRole).toString(),
+ QStringLiteral("m0@example.org"));
+
+ // And it is the FIRST message, not just any of them: the reply must still
+ // report its own.
+ QCOMPARE(model.index(0, 0, root).data(ThreadListModel::MessageIdRole)
+ .toString(),
+ QStringLiteral("m1@example.org"));
+}
+
void TestThreadListModel::replyShowsOnlyItsOwnTags()
{
ThreadListModel model;
@@ -1040,7 +1111,14 @@ void TestThreadListModel::replyShowsOnlyItsOwnTags()
// Two the thread already has, one it does not.
reply.tags = { QStringLiteral("inbox"), QStringLiteral("work"),
QStringLiteral("todo") };
- model.setThreadMessages(QStringLiteral("T1"), { reply });
+
+ // Led by the thread's FIRST message, which is what the worker sends and
+ // what the root card draws. setThreadMessages drops it by position.
+ MessageNode root;
+ root.messageId = QStringLiteral("M1");
+ root.threadId = QStringLiteral("T1");
+ root.depth = 0;
+ model.setThreadMessages(QStringLiteral("T1"), { root, reply });
const QModelIndex threadIndex = model.index(0, 0);
QVERIFY(model.hasChildren(threadIndex));
@@ -1073,7 +1151,12 @@ void TestThreadListModel::replySharingEveryThreadTagShowsNone()
reply.threadId = QStringLiteral("T1");
reply.depth = 1;
reply.tags = { QStringLiteral("inbox"), QStringLiteral("work") };
- model.setThreadMessages(QStringLiteral("T1"), { reply });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("M1");
+ root.threadId = QStringLiteral("T1");
+ root.depth = 0;
+ model.setThreadMessages(QStringLiteral("T1"), { root, reply });
const QModelIndex replyIndex = model.index(0, 0, model.index(0, 0));
QVERIFY(replyIndex.isValid());