From e19e2318fc637d50b86f663b21bd97494057232d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 4 Aug 2026 10:49:02 +0200 Subject: feat(threads): mark an opened thread read after a delay Opening a thread left it tagged unread, so the unread count never matched what had actually been read and the app was quietly wrong every day it was used. Item 6 of the usability backlog. A single-shot timer, armed when a thread is selected and restarted rather than stacked, so arrowing down a list marks only the thread still selected when it fires and not every one passed through. Configurable through mark_read_delay_ms in [general], defaulting to 2000: zero marks read at once, and any negative value disables the behaviour, which is why the value is neither clamped nor warned about at either end. The automatic change deliberately does NOT go on the undo stack. It routes through sendThreadTagChange() rather than tagSelected(), because undoing an action the user never took is worse than leaving a thread read, and toggle_unread already gives them a direct way back. It still funnels through the single applyTags path; what differs is only whether the inverse is pushed, which is a window-level decision above the worker. An explicit toggle_unread cancels any pending timer, or marking a thread unread by hand would be reversed a moment later and the key would look broken. Two guards beyond the plan, both from asking what happens when a timer outlives the thread it was armed for. Arming is skipped for a thread that is not unread, so no write is scheduled that would change nothing, and the handler re-checks that its thread is still selected and still unread before writing, so a stale timer does nothing rather than tagging the wrong thread. The plan expected the rapid-arrow case to need a database and a manual check. It needs neither: ThreadListModel takes threads through appendBatch(), so the case is unit-tested. All three tests were confirmed to fail against deliberately broken versions, one arming for read threads and one creating a timer per selection instead of restarting one. Item 7 is closed in the same pass. The user verified against real mail that HTML messages already open as HTML, which is what the item asked for, so it is recorded as done with no code changed. The prefer_html key it floated was not added: nobody has asked to default to plain text, and Ctrl+H already switches a thread by hand. Co-Authored-By: Claude Opus 5 --- src/mainwindow.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a0ae7c4..45db452 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -265,6 +266,14 @@ void MainWindow::buildUi() m_queryEdit->installEventFilter(this); m_queryCompleter = new QueryCompleter(m_queryEdit, m_config, this); + m_markReadTimer = new QTimer(this); + // Named so a test can observe whether it is armed without the window + // having to expose the timer or the decision that armed it. + m_markReadTimer->setObjectName(QStringLiteral("markReadTimer")); + m_markReadTimer->setSingleShot(true); + connect(m_markReadTimer, &QTimer::timeout, + this, &MainWindow::markCurrentThreadRead); + m_syncLog = new QPlainTextEdit(central); m_syncLog->setReadOnly(true); m_syncLog->setMaximumHeight(120); @@ -445,6 +454,13 @@ void MainWindow::registerActions() if (!current.isValid()) return; const ThreadSummary thread = m_model->threadAt(current.row()); + + // An explicit toggle overrides the automatic one. Without this, marking + // a thread unread by hand would be undone a moment later by a timer + // armed when it was opened, and the key would look broken. + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + if (thread.isUnread()) tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); else @@ -843,6 +859,7 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, const ThreadSummary thread = m_model->threadAt(current.row()); m_currentThreadId = thread.threadId; m_messageView->setTags(thread.tags); + scheduleMarkRead(thread); QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, Q_ARG(QString, m_currentThreadId), Q_ARG(QString, m_lastQuery), @@ -932,6 +949,74 @@ void MainWindow::onSyncFinished(bool success, int exitCode) } } +void MainWindow::scheduleMarkRead(const ThreadSummary &thread) +{ + // Any pending timer belongs to a thread that is no longer on screen. + // Stopping unconditionally is what makes this a restart rather than a + // stack: arrowing down ten threads must mark only the one still selected + // when the timer finally fires. + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + + // Negative disables the behaviour entirely, per the config key. + const int delay = m_config.markReadDelayMs(); + if (delay < 0) + return; + + // Nothing to do for a thread that is already read. Checked here rather + // than in the handler so no timer is even armed, which keeps a read thread + // from arming one that would fire into a no-op write. + if (!thread.tags.contains(QStringLiteral("unread"))) + return; + + m_markReadThreadId = thread.threadId; + + // Zero means immediately, and a zero-interval timer still fires through + // the event loop rather than reentering the selection handler. + m_markReadTimer->start(delay); +} + +void MainWindow::markCurrentThreadRead() +{ + if (m_markReadThreadId.isEmpty()) + return; + + // The selection can have moved on between the timer being armed and it + // firing, and the thread can have been marked read by hand in that window. + // Both mean this timer has nothing left to do. + if (m_markReadThreadId != m_currentThreadId) { + m_markReadThreadId.clear(); + return; + } + + const QModelIndex current = m_threadView->currentIndex(); + if (!current.isValid()) { + m_markReadThreadId.clear(); + return; + } + + const ThreadSummary thread = m_model->threadAt(current.row()); + if (thread.threadId != m_markReadThreadId + || !thread.tags.contains(QStringLiteral("unread"))) { + m_markReadThreadId.clear(); + return; + } + + const QStringList threadIds = { m_markReadThreadId }; + m_markReadThreadId.clear(); + + // sendThreadTagChange, 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. + // + // 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")); +} + void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, const QString &description) { -- cgit v1.2.3