aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp20
-rw-r--r--src/config.h8
-rw-r--r--src/mainwindow.cpp85
-rw-r--r--src/mainwindow.h22
4 files changed, 135 insertions, 0 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 1a8f322..f5d2d15 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -91,6 +91,26 @@ void Config::load(const QString &path)
m_completionOnFocus =
settings.value(QStringLiteral("completion_on_focus"), false).toBool();
+ // Absent is silent, the default being 2000. Present but unparseable warns,
+ // for the same reason message_zoom does: the user asked for something and
+ // is not getting it.
+ //
+ // Zero and negative are NOT errors and must not be clamped. Zero means mark
+ // read at once, and any negative value means never, which is how the
+ // behaviour is turned off.
+ const QVariant markRead = settings.value(QStringLiteral("mark_read_delay_ms"));
+ if (markRead.isValid()) {
+ bool ok = false;
+ const int value = markRead.toString().toInt(&ok);
+ if (ok) {
+ m_markReadDelayMs = value;
+ } else {
+ addProblem(QStringLiteral("Mark-read delay '%1' is not a number; "
+ "using the default.")
+ .arg(markRead.toString()));
+ }
+ }
+
// [completion] is an ordinary section, so this one DOES take its prefix.
// ',' separates entries and '|' separates a value from its description:
// two different characters because QSettings splits comma lists itself,
diff --git a/src/config.h b/src/config.h
index 63fa541..7c6eb63 100644
--- a/src/config.h
+++ b/src/config.h
@@ -102,6 +102,13 @@ public:
/// once it is known. The manual trigger works regardless.
bool completionOnFocus() const { return m_completionOnFocus; }
+ /// How long an opened thread stays unread before it is marked read.
+ ///
+ /// Three meanings, all deliberate: a positive value is the delay in
+ /// milliseconds, 0 marks read immediately, and any negative value disables
+ /// the behaviour so a thread stays unread until toggled by hand.
+ int markReadDelayMs() const { return m_markReadDelayMs; }
+
/// User-supplied mimetype completions, APPENDED to the built-in list.
/// Appending rather than replacing means a typo cannot leave completion
/// worse off than the defaults. Mimetypes are the only completion list a
@@ -135,6 +142,7 @@ private:
QString m_notmuchConfig;
qreal m_messageZoom = 1.0;
bool m_completionOnFocus = false;
+ int m_markReadDelayMs = 2000;
QList<CompletionEntry> m_extraMimetypes;
QString m_startupQuery = QStringLiteral("Unread");
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 <QStandardPaths>
#include <QStatusBar>
#include <QTableView>
+#include <QTimer>
#include <QToolBar>
#include <QVBoxLayout>
@@ -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 &current,
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)
{
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.