aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 20:02:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 20:02:14 +0200
commitbde7409ef817089298718376e46a57b2d303cf02 (patch)
tree5fea161c839e30474db0a16c79f20b5fca976ffb
parent66f1159136de5e2f032ddc83b5b4b4bde2659291 (diff)
downloadqtmaildir-bde7409ef817089298718376e46a57b2d303cf02.tar.gz
qtmaildir-bde7409ef817089298718376e46a57b2d303cf02.zip
fix(read): repaint the card when one message is marked read
Follows the message-scoped mark-read. The user reported the write going out with nothing visible changing: the status bar counted an unsynced edit while the card stayed bold and the message pane still showed the `unread` tag, until the next query corrected it. sendMessageTagChange made no optimistic model update on purpose, because applyTagChange is keyed by THREAD and repainting a whole row for a one-message edit would claim every reply had changed too. That trade is right for an explicit tag edit and wrong for auto mark-read, where the visible change IS the feature and the delay exists to deliver it. ThreadListModel::applyMessageTagChange updates the message wherever it is held, as a child row and as `first`, and lets the thread's summary follow only when the answer is unambiguous: a thread reads as unread while ANY message does, so the tag is cleared from the thread only when no other message still carries it. For an unexpanded multi-message thread the per-message tags are not loaded, so the summary is left for the next query rather than guessed at. Mutation checked: without the call the card holds `unread` for the full timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--src/mainwindow.cpp15
-rw-r--r--src/threadlistmodel.cpp81
-rw-r--r--src/threadlistmodel.h17
-rw-r--r--tests/test_mainwindow.cpp58
4 files changed, 167 insertions, 4 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 17a5a65..ae5e8fc 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -3482,10 +3482,17 @@ void MainWindow::sendMessageTagChange(const QStringList &messageIds,
if (messageIds.isEmpty())
return;
- // No optimistic model update. applyTagChange is keyed by THREAD and would
- // repaint the whole row as though every message in it had changed, which
- // for a one-message edit is a lie the user would see and then watch
- // silently correct itself on the next query.
+ // Optimistic, but scoped to the message. applyTagChange() is keyed by
+ // THREAD and would repaint the whole row as though every message in it had
+ // changed, which for a one-message edit is a lie; applyMessageTagChange()
+ // updates that message and lets the thread's own tags follow only when the
+ // answer is unambiguous.
+ //
+ // Not optional for auto mark-read: without it the write goes out, the
+ // status bar counts an unsynced edit, and the card stays bold with
+ // `unread` on it until the next query. The user reported exactly that.
+ for (const QString &messageId : messageIds)
+ m_model->applyMessageTagChange(messageId, add, remove);
// The accounts this touches, resolved through the containing threads: the
// account is a property of the thread, and the sync needs the channel
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 3e079ed..04a5493 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -888,3 +888,84 @@ void ThreadListModel::applyTagChange(const QString &threadId,
return;
}
}
+
+void ThreadListModel::applyMessageTagChange(const QString &messageId,
+ const QStringList &added,
+ const QStringList &removed)
+{
+ const auto retag = [&added, &removed](QStringList *tags) {
+ for (const QString &tag : removed)
+ tags->removeAll(tag);
+ for (const QString &tag : added) {
+ if (!tags->contains(tag))
+ tags->append(tag);
+ }
+ };
+
+ for (int row = 0; row < m_threads.size(); ++row) {
+ ThreadNode &node = m_threads[row];
+ bool touched = false;
+
+ // `first` is a copy of the opening message rather than an alias into
+ // children, so both have to be updated when they name the same one.
+ if (node.first.messageId == messageId) {
+ retag(&node.first.tags);
+ touched = true;
+ }
+
+ for (int child = 0; child < node.children.size(); ++child) {
+ if (node.children.at(child).messageId != messageId)
+ continue;
+ retag(&node.children[child].tags);
+ touched = true;
+ const QModelIndex childIndex = index(child, 0, index(row, 0));
+ emit dataChanged(childIndex, childIndex);
+ }
+
+ // The thread has not been expanded and does not open with this
+ // message, so nothing here holds it. The summary may still need to
+ // follow, which the totalCount check below decides.
+ if (!touched && node.summary.firstMessageId != messageId
+ && node.summary.totalCount > 1) {
+ continue;
+ }
+
+ // The thread's own tags follow only when the answer is unambiguous.
+ //
+ // A thread carries `unread` while ANY of its messages does, so a
+ // one-message change can only clear it from the thread when there is
+ // nothing else left to carry it. With one message in the thread that
+ // is certain. With more, the honest answer needs every message's tags,
+ // which are only loaded once the thread has been expanded; until then
+ // the summary is left alone rather than guessed at, and the next query
+ // corrects it.
+ const bool wholeThread =
+ node.summary.totalCount <= 1
+ || (!node.children.isEmpty()
+ && node.children.size() >= node.summary.totalCount);
+ if (!wholeThread) {
+ if (touched)
+ emit dataChanged(index(row, 0), index(row, 0));
+ continue;
+ }
+
+ for (const QString &tag : removed) {
+ bool stillHeld = false;
+ for (const MessageNode &child : node.children) {
+ if (child.messageId != messageId && child.tags.contains(tag)) {
+ stillHeld = true;
+ break;
+ }
+ }
+ if (!stillHeld)
+ node.summary.tags.removeAll(tag);
+ }
+ for (const QString &tag : added) {
+ if (!node.summary.tags.contains(tag))
+ node.summary.tags.append(tag);
+ }
+
+ emit dataChanged(index(row, 0), index(row, 0));
+ return;
+ }
+}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 2b8d2b0..f7abaf8 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -273,6 +273,23 @@ public:
void applyTagChange(const QString &threadId, const QStringList &added,
const QStringList &removed);
+ /// The same, scoped to ONE message.
+ ///
+ /// Updates that message's own tags wherever it is held: as a child row if
+ /// the thread is expanded, and as `first` when it is the thread's opening
+ /// message. The thread's summary tags follow only when the change is
+ /// unambiguous for the whole thread, which for `unread` means no other
+ /// message still carries it, since a thread reads as unread while any of
+ /// its messages does.
+ ///
+ /// Exists because auto mark-read touches one message and the card still
+ /// has to stop looking unread. applyTagChange() above cannot serve that:
+ /// it rewrites the thread's tags directly, which for a multi-message
+ /// thread would claim every reply had been read.
+ void applyMessageTagChange(const QString &messageId,
+ const QStringList &added,
+ const QStringList &removed);
+
private:
/// One thread root and the message rows expanded under it.
///
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index ea42223..5347493 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -186,6 +186,7 @@ private slots:
void anUnexpandedRootRendersOneMessageNotTheConversation();
void aFirstClickIntoAnUnfocusedListStillRenders();
void autoMarkReadTouchesOnlyTheMessageOnScreen();
+ void autoMarkReadClearsUnreadOnTheCardImmediately();
void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
void autoSyncSkipsWhileABackgroundSyncIsRunning();
void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery();
@@ -6555,4 +6556,61 @@ void TestMainWindow::autoMarkReadTouchesOnlyTheMessageOnScreen()
QStringList{ QStringLiteral("root@example.org") });
}
+void TestMainWindow::autoMarkReadClearsUnreadOnTheCardImmediately()
+{
+ // Reported by the user against the message-scoped mark-read: the write
+ // went out, the status bar counted an unsynced edit, and the card stayed
+ // bold with `unread` still on it. sendMessageTagChange deliberately makes
+ // no optimistic model update, because applyTagChange is keyed by THREAD
+ // and repainting a whole row for a one-message edit would be a lie.
+ //
+ // For an explicit tag edit that trade is fine. For auto mark-read it is
+ // not: the visible change IS the feature, and the 2s delay exists to give
+ // the user that feedback.
+ //
+ // One message in the thread, so the thread's own unread state and the
+ // message's are the same fact and the card must stop reading as unread.
+ WorkerBackedWindow backed;
+ backed.setGeneralKey(QStringLiteral("mark_read_delay_ms"),
+ QStringLiteral("0"));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("only@example.org"),
+ QStringLiteral("A single message"),
+ QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("The only message."), /*unread=*/true));
+ 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());
+
+ // Unread to begin with, or the assertion below proves nothing.
+ QVERIFY2(model->data(root, ThreadListModel::TagsRole)
+ .toStringList()
+ .contains(QStringLiteral("unread")),
+ "the thread was not unread to begin with");
+
+ view->setCurrentIndex(root);
+
+ // The card must stop reading as unread without waiting for a new query.
+ QTRY_VERIFY_WITH_TIMEOUT(!model->data(root, ThreadListModel::TagsRole)
+ .toStringList()
+ .contains(QStringLiteral("unread")),
+ 15000);
+}
+
#include "test_mainwindow.moc"