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.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'src/mainwindow.h') diff --git a/src/mainwindow.h b/src/mainwindow.h index 65f9636..0eea164 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -39,6 +39,7 @@ class QPushButton; class QComboBox; class QPlainTextEdit; class QSplitter; +class QTimer; class ThreadListModel; class MessageView; @@ -117,6 +118,15 @@ private: void tagSelected(const QStringList &add, const QStringList &remove, const QString &description); + /// Starts, restarts or cancels the mark-read timer for a newly opened + /// thread. Cancels outright for a thread that is not unread, so an already + /// read thread never schedules a write that would change nothing. + void scheduleMarkRead(const ThreadSummary &thread); + + /// Removes `unread` from the thread the timer was armed for, if it is still + /// the one on screen. + void markCurrentThreadRead(); + /// Sends a tag change for a set of threads without touching the undo stack. /// Both tagSelected() and ThreadTagCommand route through this. void sendThreadTagChange(const QStringList &threadIds, @@ -168,6 +178,18 @@ private: QString m_lastQuery; QString m_currentThreadId; + /// Marks the open thread read once it has been on screen long enough. + /// + /// Single-shot and RESTARTED on every selection change, never stacked: + /// arrowing down a list must mark only the thread still selected when it + /// fires, not each one passed through. + QTimer *m_markReadTimer = nullptr; + + /// The thread m_markReadTimer will mark read. Compared against the current + /// selection when it fires, so a timer that outlives its thread does + /// nothing rather than marking the wrong one. + QString m_markReadThreadId; + /// The optimistic update awaiting confirmation, kept so a worker error can /// put the model back. Only the most recent one: mutations are sent from /// the UI thread one user action at a time. -- cgit v1.2.3