aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 18:14:58 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 18:14:58 +0200
commit2c51c9653e4e045373120d50ce026f0a752a608f (patch)
tree664f7c758d219b70346abebc83b1925936c35562 /src
parenta346bef6c08e9725f3ab41f8237f01e9eb69eea7 (diff)
downloadqtmaildir-2c51c9653e4e045373120d50ce026f0a752a608f.tar.gz
qtmaildir-2c51c9653e4e045373120d50ce026f0a752a608f.zip
feat: add the thread dashboard
A widget over a ThreadDigest: header, tags, counts, the unread list capped with a link to the rest, and an activity sparkline, scrolling under a pinned action strip. It invents no colours. ThreadDigest::unread is QVector<MessageNode> rather than QVector<MessageRef>. The dashboard draws rows for messages it never opens, which is what MessageNode exists for; MessageRef carries only what the pane needs once a message is already open and has no subject, sender or date. The struct's no-file-opening contract still holds, since all four of those fields are served from the notmuch index.
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/threaddashboard.cpp479
-rw-r--r--src/threaddashboard.h150
-rw-r--r--src/threaddigest.h60
4 files changed, 690 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 85b5e56..b933f3d 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -29,6 +29,7 @@ add_library(qtmaildir_lib STATIC
tagrules.cpp
tagrulesdialog.cpp
tagstrip.cpp
+ threaddashboard.cpp
threadlistmodel.cpp
threadlistview.cpp
mailsync.cpp
diff --git a/src/threaddashboard.cpp b/src/threaddashboard.cpp
new file mode 100644
index 0000000..8583379
--- /dev/null
+++ b/src/threaddashboard.cpp
@@ -0,0 +1,479 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "threaddashboard.h"
+
+#include <QDateTime>
+#include <QFontMetrics>
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLocale>
+#include <QPainter>
+#include <QProgressBar>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+#include "avatar.h"
+#include "cardlayout.h"
+#include "tagstrip.h"
+
+namespace {
+
+/// The gap between blocks. One constant so the pane reads as evenly spaced
+/// rather than as whatever each block happened to ask for.
+constexpr int kBlockSpacing = 12;
+
+/// The avatar beside a participant and beside an unread entry.
+int avatarSide(const QFont &font)
+{
+ // From the font's metrics, not a fixed pixel count, for the reason
+ // CardLayout::markSide records: qt6ct sets fonts in PIXELS, where
+ // pointSizeF() returns -1, so the metric is what can be measured.
+ return qMax(16, QFontMetrics(font).height() + 6);
+}
+
+/// How long ago `when` was, in words.
+///
+/// Deliberately coarse. The dashboard answers "is this stale", not "at what
+/// minute did this arrive", and the exact timestamp is one click away on the
+/// message itself.
+QString relativeTime(const QDateTime &when)
+{
+ if (!when.isValid())
+ return QString();
+
+ const qint64 seconds = when.secsTo(QDateTime::currentDateTime());
+ if (seconds < 60)
+ return ThreadDashboard::tr("just now");
+ if (seconds < 3600)
+ return ThreadDashboard::tr("%1 min ago").arg(seconds / 60);
+ if (seconds < 86400)
+ return ThreadDashboard::tr("%1 h ago").arg(seconds / 3600);
+ if (seconds < 86400 * 30)
+ return ThreadDashboard::tr("%1 d ago").arg(seconds / 86400);
+ return CardLayout::formatDate(when);
+}
+
+/// A label for a value that came from a stranger.
+///
+/// Qt::PlainText EXPLICITLY, on every one of them. A QLabel guesses under
+/// Qt::AutoText, so a subject or a display name carrying markup would be
+/// interpreted rather than shown. This is the same security property
+/// MessageDetailsDialog states at each of its value labels, and it is not a
+/// style choice: escaping into a rich-text label is the same protection one
+/// mistake away from failing.
+QLabel *untrustedLabel(const QString &text)
+{
+ auto *label = new QLabel(text);
+ label->setTextFormat(Qt::PlainText);
+ return label;
+}
+
+} // namespace
+
+ActivitySparkline::ActivitySparkline(QWidget *parent) : QWidget(parent)
+{
+ setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+}
+
+void ActivitySparkline::setBuckets(const QVector<int> &buckets, int busiest)
+{
+ m_buckets = buckets;
+ m_busiest = busiest;
+ update();
+}
+
+QSize ActivitySparkline::sizeHint() const
+{
+ // Scaled off the font rather than fixed, so the bars keep their proportion
+ // to the text beside them at any desktop font size.
+ return QSize(120, qMax(24, QFontMetrics(font()).height() * 2));
+}
+
+void ActivitySparkline::paintEvent(QPaintEvent *)
+{
+ if (m_buckets.isEmpty())
+ return;
+
+ int tallest = 0;
+ for (int n : m_buckets)
+ tallest = qMax(tallest, n);
+ if (tallest <= 0)
+ return;
+
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing, false);
+
+ // Two colours, both already meaning something: the palette's text at low
+ // opacity for the bars, and its highlight for the busiest bucket. A bucket
+ // is a quantity, not a category, so nothing here is a new colour.
+ QColor bar = palette().color(QPalette::Text);
+ bar.setAlpha(90);
+ const QColor busy = palette().color(QPalette::Highlight);
+
+ const int gap = 2;
+ const int count = m_buckets.size();
+ const int available = width() - gap * (count - 1);
+ if (available <= 0)
+ return;
+ const int barWidth = qMax(1, available / count);
+
+ for (int i = 0; i < count; ++i) {
+ const int x = i * (barWidth + gap);
+ // A non-empty bucket always gets at least one pixel, so "some activity"
+ // and "none at all" cannot render identically.
+ const int barHeight =
+ m_buckets.at(i) == 0
+ ? 0
+ : qMax(1, m_buckets.at(i) * height() / tallest);
+ if (barHeight == 0)
+ continue;
+ painter.fillRect(QRect(x, height() - barHeight, barWidth, barHeight),
+ i == m_busiest ? busy : bar);
+ }
+}
+
+ThreadDashboard::ThreadDashboard(QWidget *parent) : QWidget(parent)
+{
+ auto *outer = new QVBoxLayout(this);
+ outer->setContentsMargins(0, 0, 0, 0);
+ outer->setSpacing(0);
+
+ // The content scrolls; the action strip below is its SIBLING, outside the
+ // scroll area, so the actions stay on the pane's bottom edge. Hunting for
+ // them past a long participant list is the case the scrolling exists for.
+ auto *scroll = new QScrollArea;
+ scroll->setWidgetResizable(true);
+ scroll->setFrameShape(QFrame::NoFrame);
+ outer->addWidget(scroll, 1);
+
+ auto *content = new QWidget;
+ auto *layout = new QVBoxLayout(content);
+ layout->setContentsMargins(12, 12, 12, 12);
+ layout->setSpacing(kBlockSpacing);
+ scroll->setWidget(content);
+
+ // Block 1: the header.
+ m_subject = untrustedLabel(QString());
+ m_subject->setWordWrap(true);
+ QFont subjectFont = m_subject->font();
+ subjectFont.setBold(true);
+ m_subject->setFont(subjectFont);
+ layout->addWidget(m_subject);
+
+ m_summaryLine = new QLabel;
+ m_summaryLine->setTextFormat(Qt::PlainText);
+ layout->addWidget(m_summaryLine);
+
+ // The account label is configured by the user rather than received from a
+ // stranger, but it is shown as plain text like everything else here: one
+ // rule for the pane is easier to keep than an exception per label.
+ m_account = untrustedLabel(QString());
+ layout->addWidget(m_account);
+
+ m_participants = new QWidget;
+ m_participantsLayout = new QVBoxLayout(m_participants);
+ m_participantsLayout->setContentsMargins(0, 0, 0, 0);
+ m_participantsLayout->setSpacing(4);
+ layout->addWidget(m_participants);
+
+ // Block 2: the tag chips. One tier, no muting, drawn by the same strip the
+ // message pane uses so the two cannot drift.
+ m_tags = new TagStrip;
+ layout->addWidget(m_tags);
+
+ // Block 3: the counts, with the read-progress bar beneath.
+ m_counts = new QLabel;
+ m_counts->setTextFormat(Qt::PlainText);
+ layout->addWidget(m_counts);
+
+ m_progress = new QProgressBar;
+ m_progress->setTextVisible(false);
+ // Deliberately short: it is a proportion, not a control. The palette's
+ // highlight fills it, which is the one colour a quantity gets here.
+ m_progress->setMaximumHeight(6);
+ layout->addWidget(m_progress);
+
+ // Block 4: Waiting for you.
+ m_waitingHeading = new QLabel(tr("Waiting for you"));
+ m_waitingHeading->setTextFormat(Qt::PlainText);
+ QFont headingFont = m_waitingHeading->font();
+ headingFont.setBold(true);
+ m_waitingHeading->setFont(headingFont);
+ layout->addWidget(m_waitingHeading);
+
+ m_unreadBlock = new QWidget;
+ m_unreadLayout = new QVBoxLayout(m_unreadBlock);
+ m_unreadLayout->setContentsMargins(0, 0, 0, 0);
+ m_unreadLayout->setSpacing(6);
+ layout->addWidget(m_unreadBlock);
+
+ m_moreLink = new QPushButton;
+ m_moreLink->setFlat(true);
+ m_moreLink->setCursor(Qt::PointingHandCursor);
+ connect(m_moreLink, &QPushButton::clicked, this,
+ &ThreadDashboard::expandRequested);
+ layout->addWidget(m_moreLink);
+
+ m_allCaughtUp = new QLabel(tr("All caught up"));
+ m_allCaughtUp->setTextFormat(Qt::PlainText);
+ layout->addWidget(m_allCaughtUp);
+
+ // Block 5: activity.
+ m_sparkline = new ActivitySparkline;
+ layout->addWidget(m_sparkline);
+
+ m_activityLine = new QLabel;
+ m_activityLine->setTextFormat(Qt::PlainText);
+ layout->addWidget(m_activityLine);
+
+ layout->addStretch(1);
+
+ // Block 6: the pinned action strip.
+ auto *actions = new QWidget;
+ auto *actionsLayout = new QHBoxLayout(actions);
+ actionsLayout->setContentsMargins(12, 6, 12, 12);
+ actionsLayout->setSpacing(6);
+
+ auto *markRead = new QPushButton(tr("Mark all read"));
+ connect(markRead, &QPushButton::clicked, this,
+ &ThreadDashboard::markAllReadRequested);
+ actionsLayout->addWidget(markRead);
+
+ auto *archive = new QPushButton(tr("Archive"));
+ connect(archive, &QPushButton::clicked, this,
+ &ThreadDashboard::archiveRequested);
+ actionsLayout->addWidget(archive);
+
+ auto *remove = new QPushButton(tr("Delete"));
+ connect(remove, &QPushButton::clicked, this,
+ &ThreadDashboard::deleteRequested);
+ actionsLayout->addWidget(remove);
+
+ actionsLayout->addStretch(1);
+ outer->addWidget(actions, 0);
+
+ setDigest(ThreadDigest());
+}
+
+void ThreadDashboard::setTagColors(const TagColors *colours)
+{
+ m_tags->setTagColors(colours);
+}
+
+void ThreadDashboard::setThreadHeading(const QString &subject,
+ const QString &accountLabel)
+{
+ m_subject->setText(subject.isEmpty() ? tr("(no subject)") : subject);
+ m_account->setText(accountLabel);
+ m_account->setVisible(!accountLabel.isEmpty());
+}
+
+void ThreadDashboard::setTags(const QStringList &tags)
+{
+ m_tags->setTags(tags);
+ m_tags->setVisible(!tags.isEmpty());
+}
+
+int ThreadDashboard::hiddenUnreadCount() const
+{
+ return qMax(0, m_digest.unreadTotal - m_unreadShown.size());
+}
+
+void ThreadDashboard::setDigest(const ThreadDigest &digest)
+{
+ m_digest = digest;
+
+ // The cap belongs here as well as in the worker. The worker truncates so
+ // the value crossing the boundary stays small; this one is what makes the
+ // accessors truthful whatever a caller hands over, which is what task 9
+ // and the tests read.
+ m_unreadShown = digest.unread;
+ if (m_unreadShown.size() > ThreadDigest::kUnreadShown)
+ m_unreadShown.resize(ThreadDigest::kUnreadShown);
+
+ const int people = digest.senders.size();
+ QString span;
+ if (digest.firstTimestamp > 0 && digest.lastTimestamp > 0) {
+ const qint64 days =
+ (digest.lastTimestamp - digest.firstTimestamp) / 86400;
+ span = days <= 0 ? tr("today") : tr("%n day(s)", nullptr, int(days));
+ }
+ if (span.isEmpty()) {
+ m_summaryLine->setText(tr("%n person(s)", nullptr, people));
+ } else {
+ // Two facts joined by a separator, each already translated. Building
+ // the sentence from a format string keeps the word order the
+ // translator's to choose.
+ m_summaryLine->setText(
+ tr("%1 · %2").arg(tr("%n person(s)", nullptr, people), span));
+ }
+
+ rebuildParticipants();
+
+ m_counts->setText(tr("%n message(s)", nullptr, digest.totalCount) +
+ QStringLiteral(" · ") +
+ tr("%n unread", nullptr, digest.unreadTotal));
+
+ // The bar shows what has been READ, so a thread with nothing unread reads
+ // as full rather than empty.
+ m_progress->setMaximum(qMax(1, digest.totalCount));
+ m_progress->setValue(qMax(0, digest.totalCount - digest.unreadTotal));
+ m_progress->setVisible(digest.totalCount > 0);
+
+ rebuildUnread();
+
+ m_sparkline->setBuckets(digest.buckets, digest.busiestBucket);
+
+ const bool haveSpan = digest.firstTimestamp > 0 && digest.lastTimestamp > 0;
+ m_sparkline->setVisible(haveSpan);
+ m_activityLine->setVisible(haveSpan);
+ if (haveSpan) {
+ const QDateTime first =
+ QDateTime::fromSecsSinceEpoch(digest.firstTimestamp);
+ const QDateTime last =
+ QDateTime::fromSecsSinceEpoch(digest.lastTimestamp);
+ QString line = tr("%1 → %2")
+ .arg(CardLayout::formatDate(first),
+ CardLayout::formatDate(last));
+ if (digest.busiestBucket >= 0 &&
+ digest.busiestBucket < digest.buckets.size() &&
+ digest.buckets.at(digest.busiestBucket) > 0) {
+ // Which bucket was busiest, named by the date it starts on: the
+ // bucket size varies with the thread's span, so a bucket INDEX
+ // means nothing to the reader and the date is the only honest
+ // label.
+ const qint64 bucketSpan =
+ qMax<qint64>(1, (digest.lastTimestamp - digest.firstTimestamp) /
+ ThreadDigest::kBuckets);
+ const QDateTime busiest = QDateTime::fromSecsSinceEpoch(
+ digest.firstTimestamp + bucketSpan * digest.busiestBucket);
+ line += QStringLiteral(" · ") +
+ tr("busiest %1").arg(CardLayout::formatDate(busiest));
+ }
+ m_activityLine->setText(line);
+ }
+}
+
+void ThreadDashboard::rebuildParticipants()
+{
+ while (QLayoutItem *item = m_participantsLayout->takeAt(0)) {
+ delete item->widget();
+ delete item;
+ }
+
+ const int side = avatarSide(font());
+ for (const auto &sender : m_digest.senders) {
+ auto *row = new QWidget;
+ auto *rowLayout = new QHBoxLayout(row);
+ rowLayout->setContentsMargins(0, 0, 0, 0);
+ rowLayout->setSpacing(6);
+
+ // Avatar::pixmapFor unchanged, so a participant is the same squircle
+ // and the same colour they carry on every card in the list. The
+ // dashboard chooses nothing here.
+ auto *avatar = new QLabel;
+ avatar->setPixmap(Avatar::pixmapFor(
+ sender.first,
+ Avatar::initialsFor(sender.first, sender.first, QString()),
+ Avatar::fillFor(sender.first, false), side, font()));
+ avatar->setFixedSize(side, side);
+ rowLayout->addWidget(avatar);
+
+ auto *name = untrustedLabel(sender.first);
+ rowLayout->addWidget(name, 1);
+
+ auto *count = new QLabel(tr("%n message(s)", nullptr, sender.second));
+ count->setTextFormat(Qt::PlainText);
+ rowLayout->addWidget(count);
+
+ m_participantsLayout->addWidget(row);
+ }
+ m_participants->setVisible(!m_digest.senders.isEmpty());
+}
+
+void ThreadDashboard::rebuildUnread()
+{
+ while (QLayoutItem *item = m_unreadLayout->takeAt(0)) {
+ delete item->widget();
+ delete item;
+ }
+
+ const bool caughtUp = showingAllCaughtUp();
+ m_allCaughtUp->setVisible(caughtUp);
+ m_waitingHeading->setVisible(!caughtUp);
+ m_unreadBlock->setVisible(!caughtUp);
+
+ const int hidden = hiddenUnreadCount();
+ m_moreLink->setVisible(!caughtUp && hidden > 0);
+ if (hidden > 0)
+ m_moreLink->setText(tr("+%n more", nullptr, hidden));
+
+ if (caughtUp)
+ return;
+
+ const int side = avatarSide(font());
+ for (const MessageNode &node : std::as_const(m_unreadShown)) {
+ // A button rather than a label, so the entry is clickable and
+ // keyboard-reachable without the pane growing an event filter. Flat,
+ // so it reads as a row rather than as a control.
+ auto *entry = new QPushButton;
+ entry->setFlat(true);
+ entry->setCursor(Qt::PointingHandCursor);
+ entry->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+ auto *entryLayout = new QHBoxLayout(entry);
+ entryLayout->setContentsMargins(4, 2, 4, 2);
+ entryLayout->setSpacing(6);
+
+ auto *avatar = new QLabel;
+ avatar->setAttribute(Qt::WA_TransparentForMouseEvents);
+ avatar->setPixmap(Avatar::pixmapFor(
+ node.senderAddress,
+ Avatar::initialsFor(node.from, node.senderAddress, QString()),
+ Avatar::fillFor(node.from, false), side, font()));
+ avatar->setFixedSize(side, side);
+ entryLayout->addWidget(avatar);
+
+ // Sender, then subject: both come from a stranger, so both are
+ // explicitly plain text.
+ const QString who =
+ node.from.isEmpty() ? node.senderAddress : node.from;
+ auto *text = untrustedLabel(
+ who.isEmpty() ? node.subject
+ : QStringLiteral("%1 · %2").arg(who, node.subject));
+ text->setAttribute(Qt::WA_TransparentForMouseEvents);
+ entryLayout->addWidget(text, 1);
+
+ const QString when = relativeTime(node.date);
+ if (!when.isEmpty()) {
+ auto *age = new QLabel(when);
+ age->setTextFormat(Qt::PlainText);
+ age->setAttribute(Qt::WA_TransparentForMouseEvents);
+ entryLayout->addWidget(age);
+ }
+
+ const QString messageId = node.messageId;
+ connect(entry, &QPushButton::clicked, this,
+ [this, messageId] { emit messageActivated(messageId); });
+
+ m_unreadLayout->addWidget(entry);
+ }
+}
diff --git a/src/threaddashboard.h b/src/threaddashboard.h
new file mode 100644
index 0000000..0fe9773
--- /dev/null
+++ b/src/threaddashboard.h
@@ -0,0 +1,150 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QVector>
+#include <QWidget>
+
+#include "threaddigest.h"
+#include "types.h"
+
+class TagColors;
+class TagStrip;
+class QLabel;
+class QProgressBar;
+class QPushButton;
+class QVBoxLayout;
+
+/// The activity histogram, as its own widget.
+///
+/// Separate because it is the one part of the dashboard that must paint, and
+/// keeping it apart is what leaves the rest of the pane assertable without a
+/// painter. It draws ThreadDigest::buckets at a fixed height, one colour for
+/// the bars with the palette's highlight on the busiest.
+class ActivitySparkline : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit ActivitySparkline(QWidget *parent = nullptr);
+
+ void setBuckets(const QVector<int> &buckets, int busiest);
+
+ QSize sizeHint() const override;
+
+protected:
+ void paintEvent(QPaintEvent *event) override;
+
+private:
+ QVector<int> m_buckets;
+ int m_busiest = -1;
+};
+
+/// The right pane for a THREAD row: what a conversation is, rather than one of
+/// its messages.
+///
+/// A thread row stands for the conversation, so there is no single message to
+/// render. This shows the conversation itself: who is in it, what it is
+/// tagged, how much of it is unread, which messages are still waiting, and
+/// when it was busy.
+///
+/// Built once in the constructor and repopulated by setDigest(), so switching
+/// rows rebuilds no widgets.
+///
+/// **It invents no colours.** Participants reuse Avatar::pixmapFor(), tags use
+/// TagColors::colourFor() through a TagStrip, and the progress bar and the
+/// sparkline use the palette's highlight. Every colour here already means the
+/// same thing somewhere else in the application.
+class ThreadDashboard : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit ThreadDashboard(QWidget *parent = nullptr);
+
+ /// Not owned; must outlive the dashboard. Forwarded to the tag strip.
+ void setTagColors(const TagColors *colours);
+
+ /// The thread's subject and account, which the digest does not carry: it
+ /// is built from the index by the worker, while these are already on the
+ /// summary the list holds. Call before or after setDigest(), either way.
+ void setThreadHeading(const QString &subject, const QString &accountLabel);
+
+ /// The thread's tags, from the summary. The digest carries none: a
+ /// thread's tags are notmuch's union and the model already has them.
+ void setTags(const QStringList &tags);
+
+ void setDigest(const ThreadDigest &digest);
+
+ /// How many unread entries the Waiting-for-you block actually lists.
+ ///
+ /// This and the two below exist so the pane's content is assertable
+ /// without rendering it. A probe that renders and counts pixels proves
+ /// nothing here, for every reason under "Rendering probes lie".
+ int unreadCountShown() const { return m_unreadShown.size(); }
+
+ /// How many unread messages the block could not list. Zero when the list
+ /// is complete, which is also when no "+N more" link is shown.
+ ///
+ /// Taken from unreadTotal rather than from the list's own size, because
+ /// the list is capped and a count derived from it would under-report on
+ /// exactly the threads where the number matters.
+ int hiddenUnreadCount() const;
+
+ /// True when the thread has nothing unread, so the block is replaced by a
+ /// single "All caught up" line.
+ bool showingAllCaughtUp() const { return m_digest.unreadTotal == 0; }
+
+signals:
+ /// An unread entry was clicked. The pane is a dead end without this: it
+ /// has just told the user what they have not read.
+ void messageActivated(const QString &messageId);
+
+ /// The "+N more" link was clicked. Expands the thread in the LEFT pane,
+ /// where the full list already lives, and leaves the selection alone.
+ void expandRequested();
+
+ void markAllReadRequested();
+ void archiveRequested();
+ void deleteRequested();
+
+private:
+ /// Rebuilds the Waiting-for-you block from m_digest.
+ void rebuildUnread();
+
+ /// Rebuilds the participants row from m_digest.senders.
+ void rebuildParticipants();
+
+ ThreadDigest m_digest;
+ QVector<MessageNode> m_unreadShown;
+
+ QLabel *m_subject = nullptr;
+ QLabel *m_summaryLine = nullptr; ///< "N people, N days".
+ QLabel *m_account = nullptr;
+ QWidget *m_participants = nullptr;
+ QVBoxLayout *m_participantsLayout = nullptr;
+ TagStrip *m_tags = nullptr;
+ QLabel *m_counts = nullptr;
+ QProgressBar *m_progress = nullptr;
+ QLabel *m_waitingHeading = nullptr;
+ QWidget *m_unreadBlock = nullptr;
+ QVBoxLayout *m_unreadLayout = nullptr;
+ QLabel *m_allCaughtUp = nullptr;
+ QPushButton *m_moreLink = nullptr;
+ ActivitySparkline *m_sparkline = nullptr;
+ QLabel *m_activityLine = nullptr;
+};
diff --git a/src/threaddigest.h b/src/threaddigest.h
new file mode 100644
index 0000000..e78a2ac
--- /dev/null
+++ b/src/threaddigest.h
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ */
+#ifndef QTMAILDIR_THREADDIGEST_H
+#define QTMAILDIR_THREADDIGEST_H
+
+#include <QList>
+#include <QMetaType>
+#include <QPair>
+#include <QString>
+#include <QVector>
+
+#include "types.h"
+
+/// What the thread dashboard shows, as a plain value.
+///
+/// Crosses the worker boundary like every other structure here: no
+/// notmuch_* pointer, no widget, no model. Everything in it is read from the
+/// INDEX, so building one opens no message files.
+struct ThreadDigest
+{
+ QString threadId;
+
+ /// Display name and message count, most prolific first.
+ QList<QPair<QString, int>> senders;
+
+ /// The unread messages, NEWEST FIRST, at most kUnreadShown of them.
+ ///
+ /// MessageNode rather than MessageRef, because the dashboard DRAWS these
+ /// rows for messages it never opens, which is what MessageNode exists for;
+ /// MessageRef carries only what the pane needs once a message is already
+ /// open, and has no subject, sender or date. The no-file-opening contract
+ /// above still holds: all four of those are served from the index.
+ QVector<MessageNode> unread;
+
+ /// How many there really are. The list above is capped, so a count taken
+ /// from its size would under-report on exactly the threads that need the
+ /// number most.
+ int unreadTotal = 0;
+
+ int totalCount = 0;
+
+ /// Always kBuckets entries. A fixed count is what keeps the sparkline's
+ /// geometry testable; a thread spanning five days and one spanning two
+ /// years cannot share a bucket size, so the span is what varies and the
+ /// label beneath carries the truth.
+ QVector<int> buckets;
+
+ qint64 firstTimestamp = 0;
+ qint64 lastTimestamp = 0;
+ int busiestBucket = -1;
+
+ static constexpr int kBuckets = 7;
+ static constexpr int kUnreadShown = 5;
+};
+
+Q_DECLARE_METATYPE(ThreadDigest)
+
+#endif // QTMAILDIR_THREADDIGEST_H