diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-14 19:38:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-14 19:38:59 +0200 |
| commit | 66f1159136de5e2f032ddc83b5b4b4bde2659291 (patch) | |
| tree | 4252a7123516593e65ca9ac85b0b74e63b6195c0 | |
| parent | f6658a12c85edec13890cfc1409d2f604730292d (diff) | |
| download | qtmaildir-66f1159136de5e2f032ddc83b5b4b4bde2659291.tar.gz qtmaildir-66f1159136de5e2f032ddc83b5b4b4bde2659291.zip | |
fix(read): auto mark-read touches only the message on screen
Reported by the user: selecting an unexpanded thread root marked every
message in the thread read, replies included. maildir.synchronize_flags
is on, so removing `unread` rewrites Maildir filenames and the next sync
carries it to the server: mail the user never saw stops being unread
everywhere.
This was coherent while a root click rendered the whole conversation,
because everything marked read had been displayed. Removing that view
made a root render one message and left the thread-wide write in place,
so the defect arrived with the previous commit.
markCurrentThreadRead now sends m_currentMessageId, which is what the
pane rendered, through sendMessageTagChange. The thread-level `unread`
guard is dropped with it: a thread carries `unread` while ANY message in
it is unread, so it would pass a read root under unread replies and send
a write for a message already read. Scheduling still checks it, which
keeps a fully-read thread from arming a timer.
The test asserts on which worker entry point the window used, because
reading tags back cannot answer this. Three earlier versions passed
against the unfixed code: TagsRole is empty for a message row by design,
MessageOwnTagsRole subtracts thread tags and drops marks so it can never
hold `unread`, and raw node tags are not refreshed until onTagsApplied
confirms, which lands after the assertion. Mutation checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | src/mainwindow.cpp | 32 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 152 |
2 files changed, 178 insertions, 6 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index fb34fe2..17a5a65 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -3355,16 +3355,36 @@ void MainWindow::markCurrentThreadRead() } const ThreadSummary thread = m_model->threadAt(current.row()); - if (thread.threadId != m_markReadThreadId - || !thread.tags.contains(QStringLiteral("unread"))) { + if (thread.threadId != m_markReadThreadId) { m_markReadThreadId.clear(); return; } - const QStringList threadIds = { m_markReadThreadId }; + // No thread-level `unread` check here any more. It would ask the wrong + // question now that one message is marked rather than the thread: a thread + // carries `unread` while ANY message in it is unread, so a read root under + // unread replies would pass this and a write would be sent for a message + // that is already read. The scheduling side still checks it, which stops a + // fully-read thread from arming a timer at all; what survives to here is + // decided per message below. + m_markReadThreadId.clear(); - // sendThreadTagChange, NOT tagSelected: this deliberately does not go on + // The MESSAGE on screen, not the thread it belongs to. + // + // This marked the whole thread until item 66, and that was coherent while + // a root click rendered the whole conversation: everything marked read had + // been displayed. Once a root began rendering a single message, the same + // code cleared `unread` from replies the user had never seen. Not a + // cosmetic slip: maildir.synchronize_flags is on, so removing `unread` + // rewrites Maildir filenames and the next sync carries it to the server. + // + // m_currentMessageId is what the pane actually rendered, set beside the + // loadMessage that produced it. + if (m_currentMessageId.isEmpty()) + return; + + // sendMessageTagChange, NOT tagSelected: this deliberately does not go on // the undo stack. The user never took this action, so hijacking Ctrl+Z to // reverse it would undo something they did not do, and toggle_unread // already gives them a direct way to put it back. Decided 2026-08-03. @@ -3372,8 +3392,8 @@ void MainWindow::markCurrentThreadRead() // It still funnels through the one applyTags path, per CLAUDE.md; what // differs is only whether the inverse is pushed, which is a window-level // decision above the worker. - sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") }, - tr("Mark read")); + sendMessageTagChange({ m_currentMessageId }, {}, + { QStringLiteral("unread") }, tr("Mark read")); } void MainWindow::editTagsOnSelection() diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index eb678eb..ea42223 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -82,6 +82,14 @@ class WorkerBackedWindow public: /// Builds the database, writes the config and loads it. Check isValid() /// and error() before constructing the window. + /// Extra `[general]` keys, written verbatim as `key=value` lines. Set + /// before build(). Used for mark_read_delay_ms, where a test needs the + /// timer to fire promptly rather than after the two-second default. + void setGeneralKey(const QString &key, const QString &value) + { + m_extraGeneral.insert(key, value); + } + bool build() { if (!m_fixture.isValid()) { @@ -113,6 +121,10 @@ public: // went unnoticed as broken once already. out << "[general]\n" << "notmuch_config=" << m_fixture.configPath() << "\n"; + for (auto it = m_extraGeneral.cbegin(); + it != m_extraGeneral.cend(); ++it) { + out << it.key() << "=" << it.value() << "\n"; + } } file.close(); @@ -133,6 +145,7 @@ private: QTemporaryDir m_confDir; Config m_config; QString m_error; + QMap<QString, QString> m_extraGeneral; }; /// MainWindow is mostly wiring. Cases that need a real database opt into one @@ -171,6 +184,8 @@ private slots: void aWorkerBackedWindowReturnsRealThreads(); void selectingAThreadRootShowsItInTheMessagePane(); void anUnexpandedRootRendersOneMessageNotTheConversation(); + void aFirstClickIntoAnUnfocusedListStillRenders(); + void autoMarkReadTouchesOnlyTheMessageOnScreen(); void autoSyncIsNotArmedWhenDisabledOrWithNothingPending(); void autoSyncSkipsWhileABackgroundSyncIsRunning(); void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery(); @@ -6403,4 +6418,141 @@ void TestMainWindow::anUnexpandedRootRendersOneMessageNotTheConversation() "the pane rendered a conversation, not a single message"); } +void TestMainWindow::aFirstClickIntoAnUnfocusedListStillRenders() +{ + // The user's report: in a fresh session, the first click on a thread does + // not show the message; clicking again does. + // + // setCurrentIndex() cannot reproduce this, which is why the earlier probe + // passed: it updates the selection model synchronously. A real click does + // not, and onThreadSelected returns early when the clicked row is not yet + // selected, a guard that stops QTreeView's focus housekeeping from opening + // and marking-read mail nobody looked at. This test clicks the viewport. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("root@example.org"), + QStringLiteral("A conversation"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("The first message."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("reply@example.org"), + QStringLiteral("Re: A conversation"), + QStringLiteral("other@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("The reply."), true, + QStringLiteral("root@example.org"))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + MainWindow window(backed.config()); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + + QLineEdit *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY2(queryEdit, "no query bar"); + auto *view = window.findChild<ThreadListView *>(); + QVERIFY2(view, "no thread list view"); + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY2(model, "no thread list model"); + auto *pane = window.findChild<MessageView *>(); + QVERIFY2(pane, "no message view"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + // Focus stays on the query bar, as it is after typing a query. This is the + // "fresh session" state the user described: the list has never been + // clicked and has no current row. + queryEdit->setFocus(); + QVERIFY2(!view->currentIndex().isValid(), + "the list already had a current row: not a fresh list"); + QVERIFY(pane->showingPlaceholder()); + + const QModelIndex root = model->index(0, 0, QModelIndex()); + QVERIFY(root.isValid()); + const QRect rect = view->visualRect(root); + QVERIFY2(rect.isValid() && rect.height() > 0, + "the row has no geometry to click"); + + // ONE click, the first one into a list that never had focus. + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, + rect.center()); + + QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000); +} + +void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnScreen() +{ + // Reported by the user, and a real data defect rather than a cosmetic one: + // selecting an unexpanded thread root marked EVERY message in the thread + // read, including replies never displayed. maildir.synchronize_flags is + // on, so that rewrites filenames and reaches the server: mail the user has + // not seen stops being unread everywhere. + // + // It was coherent while a root click rendered the whole conversation. It + // stopped being coherent when that view was removed and a root began + // rendering one message, which is the change that made this urgent. + WorkerBackedWindow backed; + backed.setGeneralKey(QStringLiteral("mark_read_delay_ms"), + QStringLiteral("0")); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("root@example.org"), + QStringLiteral("A conversation"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("The first message."), /*unread=*/true)); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("reply@example.org"), + QStringLiteral("Re: A conversation"), + QStringLiteral("other@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("The reply."), /*unread=*/true, + QStringLiteral("root@example.org"))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + MainWindow window(backed.config()); + + QLineEdit *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY2(queryEdit, "no query bar"); + auto *view = window.findChild<ThreadListView *>(); + QVERIFY2(view, "no thread list view"); + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY2(model, "no thread list model"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + const QModelIndex root = model->index(0, 0, QModelIndex()); + QVERIFY(root.isValid()); + view->setCurrentIndex(root); + + // Assert on WHAT THE WINDOW ASKED FOR, which is synchronous and exact. + // + // Reading tags back does not work here, and three separate attempts failed + // vacuously before this. TagsRole returns an empty list for a message row + // by design (the tag strip is a thread-wide band). MessageOwnTagsRole + // subtracts the parent thread's tags AND drops any tag drawn as a mark, + // and `unread` is one, so it can never contain it. And the raw node tags + // are not refreshed until onTagsApplied() confirms the write, which lands + // after this test's window: the model reports pre-write tags whichever + // scope was used. Each of those passed against the unfixed code. + // + // The two scopes reach the worker through different entry points, and the + // window records which one it used. That is the actual difference the fix + // makes. + QTRY_VERIFY_WITH_TIMEOUT( + !window.pendingMessageIdsForTesting().isEmpty() + || !window.pendingThreadIdsForTesting().isEmpty(), + 15000); + + QVERIFY2(window.pendingThreadIdsForTesting().isEmpty(), + "the auto mark-read was sent for the whole THREAD: every reply " + "loses `unread`, including messages never displayed, and " + "maildir.synchronize_flags carries that to the server"); + QCOMPARE(window.pendingMessageIdsForTesting(), + QStringList{ QStringLiteral("root@example.org") }); +} + #include "test_mainwindow.moc" |
