aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt3
-rw-r--r--src/avatar.cpp237
-rw-r--r--src/avatar.h77
-rw-r--r--src/businesssenders.cpp168
-rw-r--r--src/businesssenders.h88
-rw-r--r--src/carddelegate.cpp78
-rw-r--r--src/carddelegate.h45
-rw-r--r--src/cardlayout.cpp10
-rw-r--r--src/cardlayout.h10
-rw-r--r--src/mainwindow.cpp225
-rw-r--r--src/mainwindow.h94
-rw-r--r--src/notmuchworker.cpp218
-rw-r--r--src/notmuchworker.h37
-rw-r--r--src/pendingchangesdialog.cpp131
-rw-r--r--src/pendingchangesdialog.h86
-rw-r--r--src/threadlistmodel.cpp24
-rw-r--r--src/threadlistmodel.h9
-rw-r--r--src/types.h77
18 files changed, 1587 insertions, 30 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4d9dbaa..591e95f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -8,7 +8,9 @@ add_library(qtmaildir_lib STATIC
htmlbuilder.cpp
cidschemehandler.cpp
cardlayout.cpp
+ avatar.cpp
busyindicator.cpp
+ businesssenders.cpp
marks.cpp
carddelegate.cpp
notmuchworker.cpp
@@ -33,6 +35,7 @@ add_library(qtmaildir_lib STATIC
threadcidmap.cpp
messageview.cpp
messagedetailsdialog.cpp
+ pendingchangesdialog.cpp
mainwindow.cpp
querycompleter.cpp
rulequery.cpp
diff --git a/src/avatar.cpp b/src/avatar.cpp
new file mode 100644
index 0000000..9b292a2
--- /dev/null
+++ b/src/avatar.cpp
@@ -0,0 +1,237 @@
+/*
+ * 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 "avatar.h"
+
+#include <QCryptographicHash>
+#include <QLinearGradient>
+#include <QLineF>
+#include <QPainter>
+#include <QPainterPath>
+
+namespace {
+
+/// The bare display name from whatever the card's first line holds.
+///
+/// That string is the RAW header for a reply row (`Name <addr>`) and a
+/// comma-joined author summary for a thread row, so a naive space split gives
+/// `T<` for one and one letter from each of two different people for the
+/// other. Take the first entry, drop the angle-addr and any quoting, and
+/// report nothing when what remains is itself an address: the caller's address
+/// branch reads that far better than the local part would.
+QString displayNameOf(const QString &raw)
+{
+ QString name = raw.trimmed();
+
+ // `Name <addr>`: everything before the bracket is the name. When there is
+ // nothing before it, the address itself is not a name.
+ const int bracket = name.indexOf(QLatin1Char('<'));
+ if (bracket >= 0)
+ name = name.left(bracket).trimmed();
+
+ // A comma joins either two authors or a `"Rossi, Mario"` quoted name. The
+ // quotes tell them apart, so strip them only after splitting.
+ if (!name.startsWith(QLatin1Char('"'))) {
+ const int comma = name.indexOf(QLatin1Char(','));
+ if (comma >= 0)
+ name = name.left(comma).trimmed();
+ }
+ if (name.size() >= 2 && name.startsWith(QLatin1Char('"'))
+ && name.endsWith(QLatin1Char('"'))) {
+ name = name.mid(1, name.size() - 2).trimmed();
+ }
+
+ // A bare address left standing is not a name.
+ if (name.contains(QLatin1Char('@')))
+ return QString();
+ return name;
+}
+
+QString twoFrom(const QString &text)
+{
+ const QString trimmed = text.trimmed();
+ if (trimmed.size() >= 2)
+ return trimmed.left(2).toUpper();
+ if (trimmed.size() == 1)
+ return (trimmed + trimmed).toUpper();
+ return QString();
+}
+
+} // namespace
+
+namespace Avatar {
+
+QString initialsFor(const QString &displayName, const QString &address,
+ const QString &accountLabel)
+{
+ // Words, and a separator is not one. `INE - Expert IT Training` split on
+ // spaces alone gave `I-`, because the dash counted as the second word.
+ QStringList words;
+ const QStringList parts = displayNameOf(displayName)
+ .split(QLatin1Char(' '), Qt::SkipEmptyParts);
+ for (const QString &part : parts) {
+ // Trim leading punctuation so `(Team)` still contributes its `T`.
+ int at = 0;
+ while (at < part.size() && !part.at(at).isLetterOrNumber())
+ ++at;
+ if (at < part.size())
+ words.append(part.mid(at));
+ }
+ if (words.size() >= 2) {
+ return (words.at(0).left(1) + words.at(1).left(1)).toUpper();
+ }
+ if (words.size() == 1) {
+ const QString one = twoFrom(words.at(0));
+ if (!one.isEmpty())
+ return one;
+ }
+
+ // No usable name. The local part and the domain each give one letter,
+ // which never degrades to a single letter the way the local part alone
+ // would, and never reads as a truncated word.
+ const int at = address.indexOf(QLatin1Char('@'));
+ if (at > 0) {
+ const QString local = address.left(at).trimmed();
+ const QString domain = address.mid(at + 1).trimmed();
+ if (!local.isEmpty() && !domain.isEmpty())
+ return (local.left(1) + domain.left(1)).toUpper();
+ }
+ // An address with no `@` is still something to show.
+ const QString bare = twoFrom(address);
+ if (!bare.isEmpty())
+ return bare;
+
+ const QString account = twoFrom(accountLabel);
+ if (!account.isEmpty())
+ return account;
+
+ // Nothing at all. Two characters regardless, so the shape never breaks.
+ return QStringLiteral("??");
+}
+
+Fill fillFor(const QString &displayName, bool isBusinessSender)
+{
+ // The list first: it is the user's explicit override and must beat the
+ // heuristic, or a listed sender could never be pinned.
+ if (isBusinessSender)
+ return Fill::TwoTone;
+ // The same normalisation initialsFor() uses: a raw `<addr>` header or a
+ // bare address is not a display name, so it must not read as a person.
+ return displayNameOf(displayName).isEmpty() ? Fill::TwoTone
+ : Fill::Identicon;
+}
+
+QColor colourFor(const QString &address)
+{
+ // The same construction TagColors::colourFor() uses for a tag with nothing
+ // configured: hashed so it is stable, at a fixed saturation and lightness
+ // so it cannot come out neon and cannot lose its contrast with the
+ // initials. The lightness differs from that function's deliberately: a
+ // chip carries dark text, a squircle carries white.
+ const QByteArray digest =
+ QCryptographicHash::hash(address.toUtf8(), QCryptographicHash::Md5);
+ const int hue = static_cast<quint8>(digest.at(0)) * 360 / 256;
+ return QColor::fromHsl(hue, 110, 95);
+}
+
+QPixmap pixmapFor(const QString &seed, const QString &initials, Fill fill,
+ int side, const QFont &font)
+{
+ QPixmap pixmap(side, side);
+ pixmap.fill(Qt::transparent);
+
+ const QByteArray digest =
+ QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Md5);
+ const QColor base = colourFor(seed);
+
+ QPainter painter(&pixmap);
+ painter.setRenderHint(QPainter::Antialiasing, true);
+
+ // The squircle. A rounded rect at ~30% of the side reads as one without
+ // needing a superellipse, and clipping to it means neither fill has to
+ // know the shape.
+ QPainterPath squircle;
+ squircle.addRoundedRect(QRectF(0, 0, side, side), side * 0.3, side * 0.3);
+ painter.setClipPath(squircle);
+
+ if (fill == Fill::Identicon) {
+ // A 5x5 grid, mirrored about the vertical axis, so only the left
+ // three columns come from the hash: 15 cells, one bit each, which is
+ // two bytes of the digest. Symmetry is what makes the shape read as a
+ // deliberate mark rather than as noise.
+ painter.fillRect(QRect(0, 0, side, side), base.darker(220));
+ const qreal cell = qreal(side) / 5.0;
+ for (int col = 0; col < 3; ++col) {
+ for (int row = 0; row < 5; ++row) {
+ const int bit = col * 5 + row;
+ const bool on =
+ (static_cast<quint8>(digest.at(bit / 8)) >> (bit % 8)) & 1;
+ if (!on)
+ continue;
+ painter.fillRect(QRectF(col * cell, row * cell, cell, cell),
+ base);
+ const int mirrored = 4 - col;
+ painter.fillRect(
+ QRectF(mirrored * cell, row * cell, cell, cell), base);
+ }
+ }
+ // The veil. Without it the initials sit on whatever the pattern
+ // happens to do behind them, which is the classic legibility failure
+ // this fill invites. Tune the opacity against the real font before
+ // calling it done.
+ painter.fillRect(QRect(0, 0, side, side), QColor(0, 0, 0, 77));
+ } else {
+ // Two related hues split at an angle, both from the hash. The field
+ // behind the letters stays large and flat, which is the whole reason
+ // this fill exists beside the identicon.
+ const int angle = static_cast<quint8>(digest.at(1)) * 360 / 256;
+ // The axis must span the DIAMETER through the centre, not a radius
+ // from it. fromPolar() starts at the origin, so translating by half
+ // the side put p1 at the centre and the 0.5 colour stop on the
+ // squircle's edge: one hue filled almost the whole face and the fill
+ // read as flat. The diagonal, so the split still crosses the shape at
+ // any angle.
+ const qreal reach = side * 0.71;
+ QLineF axis = QLineF::fromPolar(reach, angle);
+ axis.translate(side / 2.0, side / 2.0);
+ QLineF back = QLineF::fromPolar(reach, angle + 180.0);
+ back.translate(side / 2.0, side / 2.0);
+ QLinearGradient gradient(back.p2(), axis.p2());
+ gradient.setColorAt(0.0, base);
+ gradient.setColorAt(0.499, base);
+ gradient.setColorAt(0.5, base.darker(135));
+ gradient.setColorAt(1.0, base.darker(135));
+ painter.fillRect(QRect(0, 0, side, side), gradient);
+ }
+
+ // The letters. White with a soft shadow rather than a computed contrast
+ // colour: the fills are generated at a fixed lightness precisely so one
+ // choice works for all of them.
+ QFont letters = font;
+ letters.setBold(true);
+ letters.setPixelSize(qMax(8, int(side * 0.36)));
+ painter.setFont(letters);
+ painter.setPen(QColor(0, 0, 0, 120));
+ painter.drawText(QRect(1, 1, side, side), Qt::AlignCenter, initials);
+ painter.setPen(Qt::white);
+ painter.drawText(QRect(0, 0, side, side), Qt::AlignCenter, initials);
+
+ return pixmap;
+}
+
+} // namespace Avatar
diff --git a/src/avatar.h b/src/avatar.h
new file mode 100644
index 0000000..8415022
--- /dev/null
+++ b/src/avatar.h
@@ -0,0 +1,77 @@
+/*
+ * 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 <QColor>
+#include <QFont>
+#include <QPixmap>
+#include <QString>
+
+/// A card's sender avatar: which letters it carries and what fills it.
+///
+/// A NAMESPACE of free functions over values, deliberately, for the reason
+/// CardLayout is a struct with no painter: the letters and the fill choice are
+/// decisions with right answers, and they must be assertable without a widget,
+/// a model or an exposed view. Only pixmapFor() touches a QPainter, and it
+/// paints into an image it owns rather than onto a widget.
+namespace Avatar
+{
+
+/// Which of the two generated fills a sender gets.
+enum class Fill
+{
+ /// A 5x5 symmetric grid from the hash bits, under a darkening veil.
+ Identicon,
+ /// Two related hues from the hash, split at an angle, initials on a large
+ /// flat field.
+ TwoTone,
+};
+
+/// Always exactly two characters, upper-cased.
+///
+/// In order: a display name of two or more words gives one letter from each of
+/// the first two; a one-word name gives its own first two; a bare address
+/// gives the first of the local part and the first of the domain; and with
+/// nothing usable, the account's label. The uniform length is the point, so
+/// every squircle reads as the same shape.
+QString initialsFor(const QString &displayName, const QString &address,
+ const QString &accountLabel);
+
+/// Which fill, given whether the list claims this address as a business one.
+///
+/// The list wins first, then the presence of a display name. That order is
+/// what lets `Ian Farrell <notifications@github.com>` read as a person while
+/// a listed address stays a business whatever name it presents.
+Fill fillFor(const QString &displayName, bool isBusinessSender);
+
+/// A stable colour for an address. Same input, same colour, always.
+///
+/// Generated at a FIXED saturation and lightness so the initials keep their
+/// contrast in both themes, exactly as TagColors::colourFor() does for a tag
+/// with nothing configured.
+QColor colourFor(const QString &address);
+
+/// The finished squircle, `side` pixels a side, ready to draw.
+///
+/// `seed` is what the fill is generated from, normally the sender's address
+/// and the account's own address when there is no sender.
+QPixmap pixmapFor(const QString &seed, const QString &initials, Fill fill,
+ int side, const QFont &font);
+
+} // namespace Avatar
diff --git a/src/businesssenders.cpp b/src/businesssenders.cpp
new file mode 100644
index 0000000..ab9394d
--- /dev/null
+++ b/src/businesssenders.cpp
@@ -0,0 +1,168 @@
+/*
+ * 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 "businesssenders.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QStandardPaths>
+#include <QTextStream>
+
+namespace BusinessSenders {
+
+List parse(const QString &contents)
+{
+ List list;
+ const QStringList lines = contents.split(QLatin1Char('\n'));
+ for (const QString &raw : lines) {
+ const QString line = raw.trimmed();
+ // A commented entry is the reject gesture: it stays in the file so it
+ // is never proposed again, and it is not applied.
+ if (line.isEmpty() || line.startsWith(QLatin1Char('#')))
+ continue;
+
+ const QString entry = line.toLower();
+ if (entry.startsWith(QLatin1Char('@')))
+ list.domains.insert(entry.mid(1));
+ else
+ list.addresses.insert(entry);
+ }
+ return list;
+}
+
+List load(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
+ return List();
+ return parse(QString::fromUtf8(file.readAll()));
+}
+
+bool contains(const List &list, const QString &address)
+{
+ const QString lowered = address.trimmed().toLower();
+ if (lowered.isEmpty())
+ return false;
+ if (list.addresses.contains(lowered))
+ return true;
+
+ const int at = lowered.indexOf(QLatin1Char('@'));
+ if (at < 0)
+ return false;
+ return list.domains.contains(lowered.mid(at + 1));
+}
+
+QString defaultPath()
+{
+ const QString base = QStandardPaths::writableLocation(
+ QStandardPaths::GenericConfigLocation);
+ return QDir(base).filePath(
+ QStringLiteral("qtmaildir/business-senders"));
+}
+
+bool looksLikeBulk(const QString &address)
+{
+ static const QStringList kBulkLocalParts {
+ QStringLiteral("noreply"), QStringLiteral("no-reply"),
+ QStringLiteral("donotreply"), QStringLiteral("do-not-reply"),
+ QStringLiteral("info"), QStringLiteral("support"),
+ QStringLiteral("billing"), QStringLiteral("newsletter"),
+ QStringLiteral("notifications"), QStringLiteral("mailer-daemon"),
+ };
+ const int at = address.indexOf(QLatin1Char('@'));
+ if (at <= 0)
+ return false;
+ const QString local = address.left(at).toLower();
+ for (const QString &candidate : kBulkLocalParts) {
+ if (local == candidate || local.startsWith(candidate))
+ return true;
+ }
+ return false;
+}
+
+void appendCandidates(const QString &path, const QHash<QString, int> &counts)
+{
+ // Every address the file MENTIONS, active or rejected. Parsed separately
+ // from parse() above, which deliberately drops comments: here a comment is
+ // exactly what must be remembered.
+ QSet<QString> mentioned;
+ bool needsLeadingNewline = false;
+ QFile existing(path);
+ if (existing.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ const QString contents = QString::fromUtf8(existing.readAll());
+ // Hand-editing (the documented workflow) can leave the file without a
+ // trailing newline; appending then glues the first addition onto the
+ // last entry and silently breaks it. Write a newline before the
+ // additions in that case.
+ if (!contents.isEmpty() && !contents.endsWith(QLatin1Char('\n')))
+ needsLeadingNewline = true;
+ const QStringList lines = contents.split(QLatin1Char('\n'));
+ for (const QString &raw : lines) {
+ QString line = raw.trimmed();
+ if (line.startsWith(QLatin1Char('#')))
+ line = line.mid(1).trimmed();
+ if (line.isEmpty())
+ continue;
+ // "noreply@cofidis.it (47 messages)" mentions the address before
+ // its count.
+ mentioned.insert(line.section(QLatin1Char(' '), 0, 0).toLower());
+ }
+ existing.close();
+ }
+
+ QStringList additions;
+ for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
+ const QString address = it.key().trimmed().toLower();
+ if (address.isEmpty() || mentioned.contains(address))
+ continue;
+ if (!looksLikeBulk(address))
+ continue;
+ additions.append(QStringLiteral("# %1 (%2 messages)")
+ .arg(address)
+ .arg(it.value()));
+ }
+ if (additions.isEmpty())
+ return;
+
+ additions.sort();
+
+ QDir().mkpath(QFileInfo(path).absolutePath());
+ QFile file(path);
+ if (!file.open(QIODevice::Append | QIODevice::Text))
+ return;
+ QTextStream out(&file);
+ if (needsLeadingNewline)
+ out << '\n';
+ for (const QString &line : additions)
+ out << line << '\n';
+}
+
+QString scanQuery(const QString &path)
+{
+ // "*" is notmuch's match-everything. An EMPTY string would also match
+ // everything, which is why Config::matchNothingQuery() exists elsewhere in
+ // this codebase; being explicit here means a reader never has to wonder
+ // which of the two an empty return meant.
+ const List existing = load(path);
+ if (existing.addresses.isEmpty() && existing.domains.isEmpty())
+ return QStringLiteral("*");
+ return QStringLiteral("date:1week..");
+}
+
+} // namespace BusinessSenders
diff --git a/src/businesssenders.h b/src/businesssenders.h
new file mode 100644
index 0000000..3065ae5
--- /dev/null
+++ b/src/businesssenders.h
@@ -0,0 +1,88 @@
+/*
+ * 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 <QHash>
+#include <QSet>
+#include <QString>
+#include <QStringList>
+
+/// The list of senders that read as businesses rather than people.
+///
+/// `~/.config/qtmaildir/business-senders`, plain text, one entry per line,
+/// `#` comments, blank lines ignored. Deliberately NOT in qtmaildir.conf and
+/// deliberately not INI: the user's stated workflow is grep-and-edit, QSettings
+/// would fight a bare list, and the main config is already large.
+///
+/// An entry is an exact address (`noreply@cofidis.it`) or a whole domain
+/// (`@cofidis.it`). No globs: a pattern language is a rule the user cannot grep
+/// for literally, which defeats the file's purpose.
+namespace BusinessSenders
+{
+
+/// Parsed entries, lower-cased. Two sets rather than one list so a lookup is a
+/// hash probe per repaint rather than a walk.
+struct List
+{
+ QSet<QString> addresses;
+ QSet<QString> domains; ///< Stored WITHOUT the leading '@'.
+};
+
+List parse(const QString &contents);
+
+/// Reads `path`. A missing or unreadable file yields an empty list rather than
+/// an error: the feature is cosmetic and must never block startup.
+List load(const QString &path);
+
+bool contains(const List &list, const QString &address);
+
+/// `~/.config/qtmaildir/business-senders`, built from
+/// QStandardPaths::GenericConfigLocation.
+QString defaultPath();
+
+/// True when a local part looks like bulk mail rather than a person.
+///
+/// A GUESS, and openly one. It misses senders and proposes wrong ones, which
+/// is exactly why nothing it produces takes effect until the user uncomments
+/// it.
+bool looksLikeBulk(const QString &address);
+
+/// Appends anything in `counts` that looks like bulk and is not already in the
+/// file, COMMENTED OUT, with its message count.
+///
+/// Two rules, both load-bearing. It never writes an uncommented entry, so
+/// nothing on screen changes until the user acts. And it skips an address
+/// already present in ANY form, commented or not, so an entry the user
+/// rejected is never re-proposed, and one they deleted only returns if that
+/// sender writes again.
+void appendCandidates(const QString &path, const QHash<QString, int> &counts);
+
+/// The query the candidate scan should run.
+///
+/// A week of mail once the file exists, so the step stays incremental and
+/// cheap. EVERYTHING when the file is missing or holds no entries, because
+/// that is the first run: a week's mail proposes almost nothing, and the file
+/// would then take months to become useful. The whole-database scan is
+/// affordable precisely because it happens once, measured at 76 ms over 5105
+/// messages.
+///
+/// Returns notmuch query syntax, which is wire format and is never translated.
+QString scanQuery(const QString &path);
+
+} // namespace BusinessSenders
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index 9a98c69..2dd29a2 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -18,6 +18,8 @@
#include "carddelegate.h"
+#include "avatar.h"
+#include "businesssenders.h"
#include "cardlayout.h"
#include "marks.h"
#include "tagchip.h"
@@ -26,6 +28,7 @@
#include <QApplication>
#include <QDateTime>
#include <QGuiApplication>
+#include <QLinearGradient>
#include <QPainter>
#include <QRegularExpression>
#include <QStyle>
@@ -100,6 +103,26 @@ QColor CardDelegate::mutedChipColour(const QColor &chipColour)
return QColor::fromHslF(h, s * kSaturationScale, l, a);
}
+QRect CardDelegate::fadeRectFor(const QRect &card, const QRect &innermostSpine)
+{
+ // The EXCLUSIVE right edge, then a rect built from it: QRect::right() is
+ // inclusive, which is the trap CardLayout already documents.
+ // Right to left: the wash is opaque at the card's RIGHT edge, where a
+ // hard stop is the card's own boundary, and fades out before reaching the
+ // accent bar, which already states the account. Drawing it the other way
+ // put the hard stop mid-card and read as a slab.
+ //
+ // A reply's left limit is its own spine rather than the card's edge, so
+ // the wash steps right with the nesting and stays shorter.
+ const int left = innermostSpine.isEmpty() ? card.left()
+ : innermostSpine.left();
+ const int start = card.right() + 1 - int(card.width() * kFadeFraction);
+ if (card.right() + 1 <= qMax(start, left))
+ return QRect();
+ return QRect(qMax(start, left), card.top(),
+ card.right() + 1 - qMax(start, left), card.height());
+}
+
QColor CardDelegate::accentLineColour(const QColor &accountColour)
{
if (!accountColour.isValid())
@@ -147,6 +170,21 @@ QSize CardDelegate::sizeHint(const QStyleOptionViewItem &option,
return QSize(option.rect.width(), CardLayout::heightFor(option.font));
}
+QPixmap CardDelegate::avatarFor(const QString &senderAddress,
+ const QString &senderName,
+ const QString &accountAddress,
+ const QString &accountLabel,
+ bool isBusinessSender, int side,
+ const QFont &font)
+{
+ const bool haveSender = !senderAddress.trimmed().isEmpty();
+ const QString seed = haveSender ? senderAddress : accountAddress;
+ const QString initials =
+ Avatar::initialsFor(senderName, senderAddress, accountLabel);
+ const Avatar::Fill fill = Avatar::fillFor(senderName, isBusinessSender);
+ return Avatar::pixmapFor(seed, initials, fill, side, font);
+}
+
void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
@@ -178,6 +216,30 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
root.data(ThreadListModel::AccountColourRole).value<QColor>();
const QColor lineColour = accentLineColour(accountColour);
+ // The account's fade. Under everything but the chrome, so the selection
+ // highlight and the doomed-row tint still cover it: a selected row reading
+ // mostly as selection is expected, not a fault.
+ const QRect fade =
+ fadeRectFor(option.rect,
+ card.spines.isEmpty() ? QRect() : card.spines.last());
+ if (!fade.isEmpty() && accountColour.isValid()) {
+ QColor from = lineColour;
+ // A reply's wash is weaker than its root's, so an expanded thread
+ // reads as one block with the root leading it.
+ from.setAlphaF(card.accentRect.isEmpty() ? 0.14 : 0.30);
+ // Right to left: opaque at the fade's far end, transparent where the
+ // accent bar already carries the colour. Drawing it the other way put
+ // the strongest wash under the bar, which is the one place the account
+ // is already stated.
+ QLinearGradient gradient(fade.topRight(), fade.topLeft());
+ // Opaque at the right edge, gone at the left, so the only hard stop
+ // is the card's own boundary.
+ gradient.setColorAt(0.0, from);
+ from.setAlphaF(0.0);
+ gradient.setColorAt(1.0, from);
+ painter->fillRect(fade, gradient);
+ }
+
// The accent bar, thread cards only. Drawn after the chrome so the
// selection highlight cannot cover it: which account a card belongs to
// must stay readable on the row the user is looking at.
@@ -201,6 +263,22 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
painter->fillRect(spine, spineColour);
}
+ // The sender's squircle, in the layout's reserved gutter. Drawn before the
+ // text so a wide avatar can never overprint the sender line.
+ if (!card.avatarRect.isEmpty()) {
+ const QString senderAddress =
+ index.data(ThreadListModel::SenderAddressRole).toString();
+ const QString senderName =
+ index.data(ThreadListModel::SenderNameRole).toString();
+ painter->drawPixmap(
+ card.avatarRect,
+ avatarFor(senderAddress, senderName, m_accountAddress,
+ m_accountLabel,
+ BusinessSenders::contains(m_businessSenders,
+ senderAddress),
+ card.avatarRect.width(), option.font));
+ }
+
// Selection outranks the model's foreground, and the order matters: a read
// card carries a dimmed colour blended against the UNSELECTED background,
// so over the highlight it lands grey-on-highlight and close to unreadable.
diff --git a/src/carddelegate.h b/src/carddelegate.h
index 1846359..cfbd23e 100644
--- a/src/carddelegate.h
+++ b/src/carddelegate.h
@@ -18,6 +18,7 @@
#pragma once
+#include "businesssenders.h"
#include "tagchip.h"
/// Paints a whole card: three lines, all of it, including the tag chips.
@@ -77,6 +78,23 @@ public:
/// Falls back to threadLineColour() for a thread with no account tag.
static QColor accentLineColour(const QColor &accountColour);
+ /// Where the account's fade runs, given the card and the row's innermost
+ /// spine (an empty rect for a thread root, which has none).
+ ///
+ /// Runs RIGHT to left: opaque at the card's right edge and gone 60% of the
+ /// width in, so the only hard stop is the card's own boundary and the
+ /// accent bar is left to state the account on its own. A reply is clamped
+ /// at its own spine, which IS its coloured left border, so its wash is
+ /// shorter as well as further right.
+ ///
+ /// Static and rect-in, rect-out so the geometry is assertable without a
+ /// painter, for the same reason CardLayout is.
+ static QRect fadeRectFor(const QRect &card, const QRect &innermostSpine);
+
+ /// How far back across the card, from its right edge, the account's
+ /// colour reaches.
+ static constexpr qreal kFadeFraction = 0.60;
+
/// A tag chip's colour, drained for the SIBLING tier (item 111).
///
/// Saturation only: hue stays so the tag is still recognisable, and
@@ -94,4 +112,31 @@ public:
/// survived exactly that kind of test.
static QSize chipSize(const QFontMetrics &metrics, const QString &text,
bool own);
+
+ /// The squircle for one row, resolved from what the model supplies.
+ ///
+ /// Falls back to the ACCOUNT when the row has no sender address, so every
+ /// card carries an avatar rather than a hole: the seed becomes the
+ /// account's own address and the letters come from its label.
+ static QPixmap avatarFor(const QString &senderAddress,
+ const QString &senderName,
+ const QString &accountAddress,
+ const QString &accountLabel,
+ bool isBusinessSender, int side,
+ const QFont &font);
+
+ void setAccountAddress(const QString &address)
+ {
+ m_accountAddress = address;
+ }
+ void setAccountLabel(const QString &label) { m_accountLabel = label; }
+ void setBusinessSenders(const BusinessSenders::List &list)
+ {
+ m_businessSenders = list;
+ }
+
+private:
+ QString m_accountAddress;
+ QString m_accountLabel;
+ BusinessSenders::List m_businessSenders;
};
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
index d32935b..a9bbe52 100644
--- a/src/cardlayout.cpp
+++ b/src/cardlayout.cpp
@@ -175,6 +175,16 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
const int indent = depth * kIndentStep;
out.contentLeft = textLeft + kPaddingX + indent;
+ // The avatar, square, in the gutter between the indent and the text.
+ // Sized from the card's HEIGHT rather than from a pixel constant, so it
+ // follows the desktop's font exactly as markSide() does.
+ const int avatarSide = qMax(0, rect.height() - kPaddingY * 2);
+ out.avatarRect = QRect(out.contentLeft, rect.top() + kPaddingY,
+ avatarSide, avatarSide);
+ // Everything after it starts past the squircle. This is what the item's
+ // cost is: a deep reply loses the gutter on top of its indent.
+ out.contentLeft = out.avatarRect.right() + 1 + kAvatarGap;
+
// One spine per level actually indented, each running the card's full
// height so an expansion reads as one continuous block.
for (int level = 0; level < depth; ++level) {
diff --git a/src/cardlayout.h b/src/cardlayout.h
index 92edd96..cd4eb76 100644
--- a/src/cardlayout.h
+++ b/src/cardlayout.h
@@ -142,6 +142,16 @@ struct CardLayout
/// without ever drawing two lines. Empty on a reply.
QRect accentRect;
+ /// The sender's avatar squircle, in its own gutter before the text.
+ ///
+ /// On EVERY row, thread and reply alike: a reply is where the sender
+ /// actually changes, so it is the row whose author is most worth seeing.
+ /// Square, and inset vertically so it does not touch the card's edges.
+ QRect avatarRect;
+
+ /// Space between the avatar and the text that follows it.
+ static constexpr int kAvatarGap = 8;
+
/// One full-height vertical line per depth level, outermost first.
QVector<QRect> spines;
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 9166588..231a9a5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -18,12 +18,15 @@
#include "mainwindow.h"
+#include <algorithm>
+
#include "maildirname.h"
#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QKeyEvent>
+#include <QMouseEvent>
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
@@ -63,6 +66,7 @@
#include "searchterm.h"
#include "tagchip.h"
#include "tagdialog.h"
+#include "pendingchangesdialog.h"
#include "savequerydialog.h"
#include "tagrulesdialog.h"
#include "threadlistmodel.h"
@@ -488,6 +492,20 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event)
// cannot fail.
}
+ // The unsynced-changes indicator opens its list on a click (item 119). A
+ // QLabel has no clicked signal, so the press is taken here rather than
+ // replacing the label with a flat QToolButton: a button would inherit the
+ // style's button metrics inside a status bar, and the label already sits
+ // correctly.
+ if (watched == m_pendingLabel
+ && event->type() == QEvent::MouseButtonRelease) {
+ auto *mouse = static_cast<QMouseEvent *>(event);
+ if (mouse->button() == Qt::LeftButton) {
+ showPendingChanges();
+ return true;
+ }
+ }
+
return QMainWindow::eventFilter(watched, event);
}
@@ -520,6 +538,17 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
buildUi();
registerActions();
+ // Both need the delegate, which buildUi() just created. The list is loaded
+ // once at startup and again only on an explicit reload, never per repaint:
+ // the painting path runs on every row of every scroll.
+ loadBusinessSenders();
+ applyCurrentAccountToDelegate();
+ // A change takes effect without a restart. Its own connect, not the one in
+ // buildSavedQueryRow(), which belongs to the filter-buttons row and is
+ // rebuilt with it.
+ connect(m_accountBox, &QComboBox::currentIndexChanged, this,
+ [this]() { applyCurrentAccountToDelegate(); });
+
// After registerActions(), not inside buildUi(): the query bar exists by
// then but the action does not, so wiring this where the field is built
// silently connected nothing and left Save query enabled on an empty
@@ -669,6 +698,11 @@ void MainWindow::buildUi()
// them together.
m_pendingLabel = new QLabel(this);
m_pendingLabel->setObjectName(QStringLiteral("pendingEdits"));
+ // Clickable, opening the list of what it counts (item 119). The cursor is
+ // the only affordance a status-bar label can carry, so it is what says
+ // this one can be opened.
+ m_pendingLabel->setCursor(Qt::PointingHandCursor);
+ m_pendingLabel->installEventFilter(this);
m_pendingLabel->hide();
statusBar()->addPermanentWidget(m_pendingLabel);
@@ -831,7 +865,8 @@ void MainWindow::buildUi()
// delegate is confined to one column's rectangle.
m_threadView = new ThreadListView(central);
m_threadView->setModel(m_model);
- m_threadView->setItemDelegate(new CardDelegate(this));
+ m_cardDelegate = new CardDelegate(this);
+ m_threadView->setItemDelegate(m_cardDelegate);
m_threadView->setHeaderHidden(true);
m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection);
@@ -2577,6 +2612,20 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::messageCountsReady,
this, &MainWindow::onRuleCountsReady);
+ // Sender counts feed the business-senders candidate list after a sync.
+ // The connection is queued, so the QHash argument must be a registered
+ // metatype; notmuchworker.cpp registers it beside SortOrder.
+ connect(m_worker, &NotmuchWorker::senderCountsReady, this,
+ [this](const QHash<QString, int> &counts) {
+ // Never applies anything: appendCandidates writes commented
+ // lines only, so nothing on screen changes until the user
+ // uncomments one. The list is then reloaded so an entry they
+ // uncommented by hand takes effect without a restart.
+ BusinessSenders::appendCandidates(
+ BusinessSenders::defaultPath(), counts);
+ loadBusinessSenders();
+ });
+
// The rules dialog is the only consumer, and it may have been closed while
// the scan was in flight. No generation counter: the tree on disk does not
// change under a query, so a late answer is still the right one.
@@ -2590,6 +2639,8 @@ void MainWindow::wireWorker()
// unrelated error would roll back a change that actually succeeded.
connect(m_worker, &NotmuchWorker::tagsApplied,
this, &MainWindow::onTagsApplied);
+ connect(m_worker, &NotmuchWorker::pendingSubjectsResolved,
+ this, &MainWindow::onPendingSubjectsResolved);
// messagesMovedFrom rather than messagesMoved: the tags a move carries can
// only be resolved once the origins are known, and that signal is the one
@@ -2794,6 +2845,27 @@ void MainWindow::selectAccountForTesting(const QString &key)
m_accountBox->setCurrentIndex(index);
}
+void MainWindow::loadBusinessSenders(const QString &path)
+{
+ m_businessSenders = BusinessSenders::load(
+ path.isEmpty() ? BusinessSenders::defaultPath() : path);
+ m_cardDelegate->setBusinessSenders(m_businessSenders);
+}
+
+void MainWindow::applyCurrentAccountToDelegate()
+{
+ const QString key = m_accountBox->currentData().toString();
+ if (key.isEmpty()) {
+ m_cardDelegate->setAccountAddress(QString());
+ m_cardDelegate->setAccountLabel(QString());
+ return;
+ }
+ const Account account = m_config.account(key);
+ m_cardDelegate->setAccountAddress(account.address);
+ m_cardDelegate->setAccountLabel(
+ account.name.isEmpty() ? account.key : account.name);
+}
+
void MainWindow::onRulePreviewRequested(const QString &query)
{
// Unscoped, deliberately. runQuery() wraps the bar's text in the selected
@@ -4265,7 +4337,6 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// assert the edits had reached the mail store when the sync is exactly
// what failed to put them there.
m_pendingTagEdits.clear();
- m_unnettablePendingEdits = 0;
// Only what this run actually carried, per the snapshot above. An
// account added by flushHeldEdits() stays, because its edit reaches the
@@ -4318,6 +4389,14 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
refreshCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
+
+ // Propose new business-sender candidates from the mail this sync
+ // delivered. Scoped by scanQuery: a week of mail once the file
+ // exists, everything on the first run.
+ QMetaObject::invokeMethod(
+ m_worker, "countSenders", Qt::QueuedConnection,
+ Q_ARG(QString,
+ BusinessSenders::scanQuery(BusinessSenders::defaultPath())));
} else if (exitCode == kSyncSkippedExitCode) {
// Skipped means the lock was never ours: some other run holds it. If
// both started inside the same poll interval the monitor will have
@@ -4386,17 +4465,9 @@ void MainWindow::onTagsApplied(const TagChange &change)
// message are two independent changes and must not cancel each other.
for (const QString &messageId : change.messageIds) {
for (const QString &tag : change.added)
- recordPendingEdit(messageId, tag, true);
+ recordPendingEdit(messageId, tag, true, change.description);
for (const QString &tag : change.removed)
- recordPendingEdit(messageId, tag, false);
- }
-
- // A change carrying no message ids cannot be netted against anything, and
- // must still register: losing an edit understates the indicator, which is
- // the direction that costs the user work.
- if (change.messageIds.isEmpty()
- && !(change.added.isEmpty() && change.removed.isEmpty())) {
- ++m_unnettablePendingEdits;
+ recordPendingEdit(messageId, tag, false, change.description);
}
updatePendingIndicator();
@@ -4753,7 +4824,6 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
// is the absence of evidence rather than evidence of success.
if (MailSync::lastRunOutcome(m_config.syncLog()) == SyncOutcome::Ok) {
m_pendingTagEdits.clear();
- m_unnettablePendingEdits = 0;
// Cleared HERE, before flushHeldEdits() below, and the ordering is
// load-bearing for the reason spelled out on the local path at
@@ -4944,7 +5014,7 @@ void MainWindow::runAutoSync()
}
void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
- bool added)
+ bool added, const QString &action)
{
const QString key = messageId + QLatin1Char('\n') + tag;
@@ -4953,12 +5023,12 @@ void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
// long session of tagging and untagging.
const auto existing = m_pendingTagEdits.constFind(key);
if (existing != m_pendingTagEdits.constEnd()) {
- if (*existing != added)
+ if (existing->added != added)
m_pendingTagEdits.erase(m_pendingTagEdits.find(key));
return;
}
- m_pendingTagEdits.insert(key, added);
+ m_pendingTagEdits.insert(key, PendingEdit{ added, action });
}
QStringList MainWindow::pendingSyncChannels() const
@@ -4996,6 +5066,14 @@ int MainWindow::pendingEditCount() const
// an edit waiting on a lock is precisely the work quitting would lose.
// Each held edit counts as one whatever its size, since it carries thread
// ids rather than message ids and cannot be netted against the map.
+ //
+ // There is no fourth term. A counter for confirmed changes carrying no
+ // message ids stood here until item 119 looked for what it held and found
+ // nothing: NotmuchWorker::applyTags() is the only emitter of tagsApplied()
+ // and returns early on an empty id list, so the change that counter
+ // existed for cannot reach this window. Every pending change can name the
+ // messages it touches, which is what lets the indicator be opened and
+ // listed in full.
const int held = int(m_heldEdits.size());
// Held MOVES count for exactly the same reason, and were missed. With no
// tag edit queued the count was 0, so the indicator stayed hidden and
@@ -5004,8 +5082,106 @@ int MainWindow::pendingEditCount() const
// is item 106's data loss, and worse here, because a dropped move leaves
// the file in the folder the user asked it out of.
const int heldMoves = int(m_heldMoves.size());
- return m_pendingTagEdits.size() + m_unnettablePendingEdits + held
- + heldMoves;
+ return m_pendingTagEdits.size() + held + heldMoves;
+}
+
+QVector<PendingChange> MainWindow::pendingChangeSnapshot() const
+{
+ QVector<PendingChange> rows;
+
+ // The netted per-(message, tag) edits. The key is `messageId\ntag`, built
+ // by recordPendingEdit(), so the id is everything before the first
+ // newline: a TAG may contain almost anything, but a message id cannot
+ // contain a newline and neither separator can be confused for the other.
+ for (auto it = m_pendingTagEdits.cbegin(); it != m_pendingTagEdits.cend();
+ ++it) {
+ const QString id = it.key().section(QLatin1Char('\n'), 0, 0);
+ rows.append(PendingChange{ id, false, it->action, QString(), -1 });
+ }
+
+ // Held THREAD edits, which stay thread-scoped: a `*_thread` action is what
+ // made them, and reporting the messages instead would claim the user acted
+ // on each one. One row per thread the edit named, since a single edit can
+ // cover a multi-row selection.
+ for (const HeldEdit &edit : m_heldEdits) {
+ for (const QString &threadId : edit.threadIds) {
+ rows.append(PendingChange{ threadId, true, edit.change.description,
+ QString(), -1 });
+ }
+ }
+
+ // Held MOVES, which are message-scoped. A move is not a tag change and is
+ // queued separately for that reason, but it is the same kind of row here:
+ // one message, one action the user took.
+ for (const HeldMove &move : m_heldMoves) {
+ for (const QString &messageId : move.messageIds)
+ rows.append(PendingChange{ messageId, false, move.description,
+ QString(), -1 });
+ }
+
+ // Grouped by id so a message with several outstanding actions appears
+ // ONCE with its actions beneath it, which is the layout the user asked
+ // for. A stable sort, so the actions under one message keep the order
+ // they were made in rather than an arbitrary one; QHash has no order of
+ // its own, so without this the list reshuffles between openings.
+ std::stable_sort(rows.begin(), rows.end(),
+ [](const PendingChange &a, const PendingChange &b) {
+ return a.id < b.id;
+ });
+ return rows;
+}
+
+void MainWindow::showPendingChanges()
+{
+ // The snapshot is taken HERE, at the click, and is what the dialog shows
+ // however long it stays open. Nothing refreshes it: the count the user
+ // clicked is the list they get.
+ m_pendingChangeRequest = pendingChangeSnapshot();
+
+ if (m_pendingChangeRequest.isEmpty() || !m_worker) {
+ // Nothing to resolve. Shown anyway rather than silently ignoring the
+ // click, since a window saying "nothing is waiting" is an answer and a
+ // dead click is not.
+ PendingChangesDialog(m_pendingChangeRequest, this).exec();
+ m_pendingChangeRequest.clear();
+ return;
+ }
+
+ QStringList ids;
+ QList<bool> areThreads;
+ ids.reserve(m_pendingChangeRequest.size());
+ areThreads.reserve(m_pendingChangeRequest.size());
+ for (const PendingChange &change : m_pendingChangeRequest) {
+ ids.append(change.id);
+ areThreads.append(change.isThread);
+ }
+
+ QMetaObject::invokeMethod(m_worker, "resolvePendingSubjects",
+ Qt::QueuedConnection,
+ Q_ARG(QStringList, ids),
+ Q_ARG(QList<bool>, areThreads));
+}
+
+void MainWindow::onPendingSubjectsResolved(const QStringList &subjects,
+ const QList<int> &messageCounts)
+{
+ // Positional, so the two must line up. A mismatch means the answer is not
+ // this request's, which is not something to render half of.
+ if (m_pendingChangeRequest.isEmpty()
+ || subjects.size() != m_pendingChangeRequest.size()
+ || messageCounts.size() != m_pendingChangeRequest.size()) {
+ m_pendingChangeRequest.clear();
+ return;
+ }
+
+ QVector<PendingChange> changes = m_pendingChangeRequest;
+ m_pendingChangeRequest.clear();
+ for (int i = 0; i < changes.size(); ++i) {
+ changes[i].subject = subjects.at(i);
+ changes[i].messageCount = messageCounts.at(i);
+ }
+
+ PendingChangesDialog(changes, this).exec();
}
void MainWindow::updatePendingIndicator()
@@ -5828,13 +6004,24 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds,
// `<maildir>/<folder>`, and the account is resolved back from it
// rather than captured above, where it belongs to the per-message loop
// and is out of scope here.
+ //
+ // The destination FOLDER, taken from the key rather than from
+ // `origin` above: that is the finished TAG, `deleted-from:Inbox`,
+ // which never equals `Inbox` however the account spells it. The
+ // comparison was therefore always false and the `inbox` tag never came
+ // back, so a restored message sat in the inbox folder invisible to the
+ // Inbox view until the next hook run. The comment above says what this
+ // does; for one release the code did not do it.
QStringList add;
const QString destMaildir = it.key().section(QLatin1Char('/'), 0, 0);
+ const QString destFolder = it.key().section(QLatin1Char('/'), 1);
for (const Account &candidate : m_config.accounts()) {
if (candidate.maildir != destMaildir)
continue;
- if (origin.compare(candidate.inboxFolder(), Qt::CaseInsensitive) == 0)
+ if (destFolder.compare(candidate.inboxFolder(),
+ Qt::CaseInsensitive) == 0) {
add.append(QStringLiteral("inbox"));
+ }
break;
}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index cb8cec4..6321573 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -43,6 +43,10 @@
#include "tagcolors.h"
#include "types.h"
+// Held by value: the parsed business-senders list is a member, and the load is
+// asserted through it without reaching into the delegate.
+#include "businesssenders.h"
+
class QAction;
class QLineEdit;
class QMenu;
@@ -57,6 +61,7 @@ class QToolButton;
class QVBoxLayout;
class BusyIndicator;
+class CardDelegate;
class ThreadListModel;
class MessageView;
class MailSync;
@@ -100,6 +105,31 @@ public:
/// the worker, which test_mainwindow has no database to drive.
bool hasEditAwaitingSend() const { return !m_heldEdits.isEmpty(); }
+ /// Every outstanding change, as rows, for the list behind the count.
+ ///
+ /// A SNAPSHOT: taken once when the user opens the list and never refreshed
+ /// under them. Subjects are empty here, filled by the resolve step, so
+ /// this is testable with no worker and no database.
+ ///
+ /// Scope follows the ACTION. The three queues already encode it: a held
+ /// thread edit carries thread ids because a `*_thread` action made it,
+ /// while a netted tag edit and a held move both carry message ids. Nothing
+ /// is expanded, and nothing is escalated.
+ /// Net changes the index holds that a sync has not carried over.
+ ///
+ /// Public beside pendingChangeSnapshot(), which must agree with it: the
+ /// count the user clicks is the count the list has to account for.
+ int pendingEditCount() const;
+
+ QVector<PendingChange> pendingChangeSnapshot() const;
+
+ /// Opens the list behind the unsynced-changes count.
+ ///
+ /// Takes the snapshot, asks the worker to resolve its subjects, and shows
+ /// the dialog when they arrive. Q_INVOKABLE so a test can open it without
+ /// synthesising a click on a status-bar label.
+ Q_INVOKABLE void showPendingChanges();
+
/// Whether the undo stack still holds anything. Exposed so a test can show
/// that a rejected write did not take unrelated history down with it.
bool canUndo() const { return m_undoStack.canUndo(); }
@@ -358,6 +388,20 @@ public:
/// The Maildir root as the worker reported it, for the split-index test.
QString mailRootForTesting() const { return m_mailRoot; }
+ /// Reads the business-senders list and hands it to the delegate.
+ ///
+ /// Once at startup and on an explicit reload, never per repaint and never
+ /// stat-per-row: the file is small and the painting path runs on every
+ /// row of every scroll.
+ void loadBusinessSenders(const QString &path = QString());
+
+ /// Test accessor, so the load can be asserted without reaching into the
+ /// delegate.
+ const BusinessSenders::List &businessSendersForTest() const
+ {
+ return m_businessSenders;
+ }
+
/// Runs save_message into \p directory instead of asking for one.
///
/// The file dialog is a modal the offscreen platform cannot click, and the
@@ -581,6 +625,10 @@ private slots:
/// A tag mutation the worker has confirmed reached the database. Counts it
/// as unsynced, since reaching the index is not reaching the mail store.
void onTagsApplied(const TagChange &change);
+
+ /// The subjects for the pending-changes list arrived; show the dialog.
+ void onPendingSubjectsResolved(const QStringList &subjects,
+ const QList<int> &messageCounts);
void onAllTagsReady(const QStringList &tags);
/// The Maildir root, answered once at startup. Enables nothing on its own:
@@ -625,6 +673,11 @@ private slots:
private:
void buildUi();
+ /// Pushes the selected account's identity into the card delegate, so the
+ /// fallback avatar is seeded from it. Empty selection ("All accounts")
+ /// clears both: the delegate then falls back to "??".
+ void applyCurrentAccountToDelegate();
+
/// Restores window geometry, splitter and thread-list header widths.
/// A missing or rejected blob leaves the buildUi() defaults in place.
void restoreUiState();
@@ -829,11 +882,14 @@ private:
/// Records one confirmed (message, tag) change, cancelling it against an
/// opposite change already outstanding for the same pair.
+ ///
+ /// `action` is the name the user would recognise, carried through so the
+ /// list behind the count can say what each change was. It is the
+ /// TagChange's own description rather than anything derived from the tag.
void recordPendingEdit(const QString &messageId, const QString &tag,
- bool added);
+ bool added, const QString &action);
+
- /// Net changes the index holds that a sync has not carried over.
- int pendingEditCount() const;
/// Shows or hides the "syncing" state: the progress bar and a disabled
/// Sync button.
@@ -1278,6 +1334,13 @@ private:
/// expander column are ThreadListView's, and holding the base here only
/// hid that from every reader.
ThreadListView *m_threadView = nullptr;
+ /// The card delegate, stored rather than discarded so the window can hand
+ /// it the account identity and the business-senders list.
+ CardDelegate *m_cardDelegate = nullptr;
+
+ /// The parsed business-senders list, for the delegate. Empty until
+ /// loadBusinessSenders() runs.
+ BusinessSenders::List m_businessSenders;
/// Right-click menu for the thread list, holding the same QActions the
/// menu bar does.
@@ -1508,12 +1571,27 @@ private:
/// value true for added and false for removed; a pair that reverts is
/// erased rather than stored, so an edit and its inverse leave nothing
/// behind and the map cannot grow without bound.
- QHash<QString, bool> m_pendingTagEdits;
+ /// What one pending (message, tag) edit is: its direction, and the name of
+ /// the action that made it.
+ ///
+ /// The direction alone was enough while this only had to be counted. The
+ /// list behind the count (item 119) has to SAY what each change was, and
+ /// only the action that made it knows: `+deleted` is a Delete and
+ /// `-unread` is a Mark read, but deriving that here would be a second
+ /// table of tag names to labels, drifting from the one the actions already
+ /// pass as TagChange::description.
+ struct PendingEdit {
+ bool added = false;
+ QString action; ///< Translated, from TagChange::description.
+ };
+ QHash<QString, PendingEdit> m_pendingTagEdits;
- /// Confirmed changes carrying no message ids, which cannot be netted
- /// against anything. Counted separately rather than dropped: understating
- /// the indicator is the direction that costs the user work.
- int m_unnettablePendingEdits = 0;
+ /// The snapshot taken when the user clicked the indicator, held while the
+ /// worker resolves its subjects. Empty when no such request is in flight.
+ ///
+ /// One request at a time: a second click before the first answers replaces
+ /// it, which is right because both would show the same thing.
+ QVector<PendingChange> m_pendingChangeRequest;
/// Marks the open thread read once it has been on screen long enough.
///
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 8ab3ab5..1f28973 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -16,6 +16,13 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
+// gmime.h pulls in glib's gio headers, which declare a struct field named
+// "signals". Qt's <QtCore/qnamespace.h> #defines "signals" to "Q_SIGNALS"
+// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt
+// header in this translation unit to avoid a macro collision. That means
+// before notmuchworker.h too, which includes Qt headers.
+#include <gmime/gmime.h>
+
#include "notmuchworker.h"
#include <notmuch.h>
@@ -74,6 +81,51 @@ QStringList tagsOf(notmuch_thread_t *thread)
return result;
}
+/// The first mailbox address in a raw address header, display names dropped.
+///
+/// Parsed with GMime rather than split: the header is untrusted and a display
+/// name may legally contain an `@`, so "Ian <a@b>" split on `@` yields
+/// nonsense.
+QString firstMailboxOf(const QString &rawHeader)
+{
+ // GMime must be initialised once per process before any parse, or the
+ // first internet_address_list_parse() call dereferences an uninitialised
+ // type registry and SEGVs. Function-local static, exactly as the other
+ // gmime-using units do, so this file does not lean on libnotmuch having
+ // initialised it as a side effect (measured 2026-08-26: it currently
+ // does, but that is not documented anywhere).
+ static const bool initialised = [] {
+ g_mime_init();
+ return true;
+ }();
+ Q_UNUSED(initialised);
+
+ const QByteArray utf8 = rawHeader.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return QString();
+
+ InternetAddressList *list =
+ internet_address_list_parse(nullptr, utf8.constData());
+ if (!list)
+ return QString();
+
+ QString address;
+ const int count = internet_address_list_length(list);
+ for (int i = 0; i < count; ++i) {
+ InternetAddress *entry = internet_address_list_get_address(list, i);
+ if (!entry || !INTERNET_ADDRESS_IS_MAILBOX(entry))
+ continue;
+ const char *addr =
+ internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(entry));
+ if (addr && *addr) {
+ address = QString::fromUtf8(addr);
+ break;
+ }
+ }
+ g_object_unref(list);
+ return address;
+}
+
/// Who a thread's messages were addressed to, summarised for one line.
///
/// EXPENSIVE, and only called when a query asks. "To" is not served from
@@ -87,7 +139,7 @@ QStringList tagsOf(notmuch_thread_t *thread)
/// frees again, and the walk finishes before the caller drops the thread. This
/// is the same rule walkReplies follows, and getting it wrong is a double-free
/// rather than a leak.
-QString recipientsOf(notmuch_thread_t *thread)
+QString recipientsOf(notmuch_thread_t *thread, QString *firstAddress)
{
// The first message with a usable To wins. A thread is one conversation,
// and the alternative, folding every message's recipients together, is the
@@ -107,12 +159,33 @@ QString recipientsOf(notmuch_thread_t *thread)
continue;
const QString summary = recipientSummary(QString::fromUtf8(to));
- if (!summary.isEmpty())
+ if (!summary.isEmpty()) {
+ // The same header, for the avatar's hash (item 169). Parsed rather
+ // than split for the reason senderAddressOf() documents: a display
+ // name may legally contain an `@`.
+ if (firstAddress)
+ *firstAddress = firstMailboxOf(QString::fromUtf8(to));
return summary;
+ }
}
return QString();
}
+/// The bare address of a message's From, with any display name discarded.
+///
+/// Index-served, unlike recipientsOf() above, which is why this is not behind
+/// the withRecipients flag: `From` is in notmuch's index and `To` is not.
+///
+/// The header is untrusted, so it is parsed rather than split: a display name
+/// may legally contain an `@`, and "Ian <a@b>" split on `@` yields nonsense.
+QString senderAddressOf(notmuch_message_t *message)
+{
+ const char *from = notmuch_message_get_header(message, "From");
+ if (!from || !*from)
+ return QString();
+ return firstMailboxOf(QString::fromUtf8(from));
+}
+
/// Collects the message ids a query matches. Returns false if the query could
/// not be run at all, which is different from a query that matched nothing.
bool collectMessageIds(notmuch_database_t *db, const QString &query,
@@ -169,6 +242,7 @@ void walkReplies(notmuch_messages_t *messages, int depth,
QString::fromUtf8(notmuch_message_get_filename(message));
node.from =
QString::fromUtf8(notmuch_message_get_header(message, "from"));
+ node.senderAddress = senderAddressOf(message);
node.subject =
QString::fromUtf8(notmuch_message_get_header(message, "subject"));
node.date =
@@ -267,10 +341,19 @@ QString folderOfMessageFile(const QString &root, const QString &filePath)
static const int kSortOrderMetaType =
qRegisterMetaType<NotmuchWorker::SortOrder>("NotmuchWorker::SortOrder");
+/// The same registration for the sender-count map. The QHash crosses the
+/// queued senderCountsReady connection from the worker thread to the UI, and
+/// an unregistered type is dropped there with a warning, exactly like
+/// SortOrder above. Registered with the name invokeMethod/moc resolve, so a
+/// caller that never builds a worker still gets the type.
+static const int kSenderCountsMetaType =
+ qRegisterMetaType<QHash<QString, int>>("QHash<QString,int>");
+
NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
: QObject(parent), m_configPath(notmuchConfigPath)
{
Q_UNUSED(kSortOrderMetaType);
+ Q_UNUSED(kSenderCountsMetaType);
}
NotmuchWorker::~NotmuchWorker()
@@ -392,7 +475,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
summary.matchedCount = notmuch_thread_get_matched_messages(thread.get());
summary.tags = tagsOf(thread.get());
if (withRecipients)
- summary.recipients = recipientsOf(thread.get());
+ summary.recipients =
+ recipientsOf(thread.get(), &summary.firstMessageRecipient);
// The message the row's card stands for. Raw pointers on purpose:
// messages reached through a thread are owned by the THREAD and freed
@@ -434,6 +518,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(message);
+ // The card's sender, for the avatar hash (item 169). Same
+ // walk, and From is in the index like the tags.
+ summary.firstMessageSender = senderAddressOf(message);
// Which account this belongs to, for Delete's destination.
summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
QString::fromUtf8(
@@ -450,6 +537,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(first);
+ // The card's sender, for the avatar hash (item 169). Same
+ // walk, and From is in the index like the tags.
+ summary.firstMessageSender = senderAddressOf(first);
// Which account this belongs to, for Delete's destination.
summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
QString::fromUtf8(
@@ -1142,6 +1232,88 @@ void NotmuchWorker::resolveThreadMessages(const QStringList &threadIds,
resolveQuery(terms.join(QStringLiteral(" or ")), requestTag);
}
+void NotmuchWorker::resolvePendingSubjects(const QStringList &ids,
+ const QList<bool> &areThreads)
+{
+ if (ids.isEmpty() || ids.size() != areThreads.size())
+ return;
+
+ QStringList subjects;
+ QList<int> counts;
+ subjects.reserve(ids.size());
+ counts.reserve(ids.size());
+
+ if (!openReadOnly()) {
+ // Answer anyway, one empty subject per row. The dialog must be able to
+ // show the user their pending changes even when the index cannot be
+ // opened: the ids and the actions are known without it, and only the
+ // subjects are missing.
+ for (int i = 0; i < ids.size(); ++i) {
+ subjects.append(QString());
+ counts.append(-1);
+ }
+ emit pendingSubjectsResolved(subjects, counts);
+ return;
+ }
+
+ // One lookup per id rather than one combined query, deliberately. The
+ // answer is POSITIONAL, and a combined query returns a set: it would lose
+ // both the order and the duplicates, and a message with two outstanding
+ // actions is exactly two rows carrying one id.
+ //
+ // The cost is bounded by what the user can have pending, which is what
+ // they did by hand since the last sync. This is not a query-sized walk.
+ for (int i = 0; i < ids.size(); ++i) {
+ QString subject;
+ int count = -1;
+
+ if (areThreads.at(i)) {
+ NmQuery query(notmuch_query_create(
+ m_db,
+ QStringLiteral("thread:%1").arg(ids.at(i)).toUtf8().constData()));
+ notmuch_threads_t *raw = nullptr;
+ if (query
+ && notmuch_query_search_threads(query.get(), &raw)
+ == NOTMUCH_STATUS_SUCCESS) {
+ NmThreads threads(raw);
+ if (notmuch_threads_valid(threads.get())) {
+ NmThread thread(notmuch_threads_get(threads.get()));
+ if (thread) {
+ subject = QString::fromUtf8(
+ notmuch_thread_get_subject(thread.get()));
+ // At snapshot time, which is what the row reports. A
+ // held thread edit applies when the sync ends, and a
+ // reply landing in between makes the real number
+ // larger; the number describes what the user is
+ // looking at, not what the write will touch.
+ count = notmuch_thread_get_total_messages(thread.get());
+ }
+ }
+ }
+ } else {
+ notmuch_message_t *raw = nullptr;
+ // find_message reports SUCCESS with a null message for an id that
+ // is not there, so both have to be checked. A missing id is not an
+ // error here: it is the stale row the dialog exists to show.
+ if (notmuch_database_find_message(
+ m_db, ids.at(i).toUtf8().constData(), &raw)
+ == NOTMUCH_STATUS_SUCCESS
+ && raw) {
+ NmMessage message(raw);
+ const char *header =
+ notmuch_message_get_header(message.get(), "Subject");
+ if (header)
+ subject = QString::fromUtf8(header);
+ }
+ }
+
+ subjects.append(subject);
+ counts.append(count);
+ }
+
+ emit pendingSubjectsResolved(subjects, counts);
+}
+
void NotmuchWorker::resolveQuery(const QString &query,
const QString &requestTag)
{
@@ -1321,6 +1493,46 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries,
emit messageCountsReady(counts, generation);
}
+void NotmuchWorker::countSenders(const QString &query)
+{
+ if (!openReadOnly()) {
+ // Answered anyway, with an empty map, so the sync path that asked is
+ // not left waiting on a signal it can never receive.
+ emit senderCountsReady({});
+ return;
+ }
+
+ QHash<QString, int> counts;
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit senderCountsReady(counts);
+ return;
+ }
+
+ notmuch_messages_t *raw = nullptr;
+ if (notmuch_query_search_messages(nmQuery.get(), &raw)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit senderCountsReady(counts);
+ return;
+ }
+
+ NmMessages messages(raw);
+ for (; notmuch_messages_valid(messages.get());
+ notmuch_messages_move_to_next(messages.get())) {
+ notmuch_message_t *message = notmuch_messages_get(messages.get());
+ if (!message)
+ continue;
+ // The BARE address, lower-cased, because that is the key
+ // BusinessSenders matches on: a display name would defeat the
+ // bulk-sender guess and a mixed-case key would duplicate one sender.
+ const QString sender = senderAddressOf(message);
+ if (!sender.isEmpty())
+ counts[sender.toLower()] += 1;
+ }
+
+ emit senderCountsReady(counts);
+}
+
void NotmuchWorker::requestMailRoot()
{
if (!openReadOnly()) {
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 2efddaa..8171f3c 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -18,6 +18,7 @@
#pragma once
+#include <QHash>
#include <QMap>
#include <QObject>
#include <QStringList>
@@ -212,6 +213,22 @@ public slots:
/// whatever the current view happens to be showing.
void resolveQueryMessages(const QString &query, const QString &requestTag);
+ /// Subjects for the list behind the unsynced-changes count (item 119).
+ ///
+ /// Takes the snapshot's ids in order, each flagged as a thread id or a
+ /// message id, and answers POSITIONALLY: one subject per input, plus a
+ /// message count for a thread id and -1 for a message id. Positional
+ /// because the caller has already decided what its rows are and in what
+ /// order; a set-based answer would make it match them back up by id, and
+ /// one id can legitimately appear on several rows.
+ ///
+ /// An id the index no longer holds yields an EMPTY subject rather than
+ /// being dropped. The dialog still shows that row: the count the user
+ /// clicked has to equal the list they are shown, and silently dropping a
+ /// row would break that for the one case where it matters most.
+ void resolvePendingSubjects(const QStringList &ids,
+ const QList<bool> &areThreads);
+
private:
/// The shared walk behind resolveMessages() and resolveThreadMessages():
/// runs `query` and emits threadMessagesResolved() with each match's id,
@@ -281,6 +298,16 @@ public slots:
/// root does not change while the application runs.
void requestMailRoot();
+ /// Counts messages per sender address over `query`.
+ ///
+ /// Index-served, so it is cheap: measured 2026-08-26 on the developer's
+ /// database, 1322 distinct senders in 12 ms over 5105 messages. It does
+ /// NOT touch m_generation, which is the QUERY generation: bumping it would
+ /// discard a thread load in flight and blank the message pane because the
+ /// user synced. Item 169, following the same rule requestMessageCounts
+ /// already follows.
+ void countSenders(const QString &query);
+
signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
@@ -329,6 +356,12 @@ signals:
const QStringList &paths,
const QStringList &tags,
const QString &requestTag);
+ /// One subject per requested id, in the SAME ORDER, and one count beside
+ /// it: the thread's message total, or -1 for a message id. An empty
+ /// subject means the index no longer holds that id.
+ void pendingSubjectsResolved(const QStringList &subjects,
+ const QList<int> &messageCounts);
+
void allTagsReady(const QStringList &tags, quint64 generation);
/// One entry per requested query, in the order they were asked for. A query
@@ -356,6 +389,10 @@ signals:
/// treat as "cannot compose a path yet" rather than as the root being "".
void mailRootReady(const QString &mailRoot);
+ /// One sender per entry, lower-cased, with how many messages over `query`
+ /// came from it. The candidate list for the business-senders file.
+ void senderCountsReady(const QHash<QString, int> &counts);
+
void errorOccurred(const QString &message);
private:
diff --git a/src/pendingchangesdialog.cpp b/src/pendingchangesdialog.cpp
new file mode 100644
index 0000000..bd9fbcb
--- /dev/null
+++ b/src/pendingchangesdialog.cpp
@@ -0,0 +1,131 @@
+/*
+ * 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 "pendingchangesdialog.h"
+
+#include <QDialogButtonBox>
+#include <QGridLayout>
+#include <QLabel>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+QVector<PendingChangeRow> PendingChangesDialog::rowsFor(
+ const QVector<PendingChange> &changes)
+{
+ QVector<PendingChangeRow> rows;
+ rows.reserve(changes.size());
+
+ // A run is a stretch of changes sharing one id, which the snapshot has
+ // already grouped. Only the first row of a run carries a subject, so the
+ // actions read as belonging to the message above them.
+ //
+ // Compared against the PREVIOUS id rather than collected into a map: the
+ // snapshot's order is deliberate (the actions under one message keep the
+ // order they were made in), and a map would discard it.
+ QString previousId;
+ bool first = true;
+ for (const PendingChange &change : changes) {
+ const bool startsMessage = first || change.id != previousId;
+ rows.append(PendingChangeRow{
+ startsMessage ? change.subject : QString(),
+ change.action,
+ startsMessage,
+ startsMessage ? change.messageCount : -1 });
+ previousId = change.id;
+ first = false;
+ }
+ return rows;
+}
+
+PendingChangesDialog::PendingChangesDialog(
+ const QVector<PendingChange> &changes, QWidget *parent)
+ : QDialog(parent), m_rows(rowsFor(changes))
+{
+ setWindowTitle(tr("Unsynced changes"));
+
+ auto *layout = new QVBoxLayout(this);
+
+ auto *intro = new QLabel(
+ tr("Changes made here that a sync has not yet carried to the mail "
+ "store. This list is a snapshot taken when it was opened."),
+ this);
+ intro->setWordWrap(true);
+ layout->addWidget(intro);
+
+ auto *content = new QWidget;
+ auto *grid = new QGridLayout(content);
+ grid->setColumnStretch(0, 1);
+
+ int line = 0;
+ for (const PendingChangeRow &row : m_rows) {
+ if (row.startsMessage) {
+ // PlainText stated rather than left to Qt, for the reason
+ // MessageDetailsDialog states it on every value: a subject comes
+ // from a stranger, and a QLabel guesses under Qt::AutoText. Plain
+ // text cannot interpret markup, so there is nothing to escape.
+ QString text = row.subject;
+ if (text.isEmpty()) {
+ // The id no longer resolves. The row stays, because the count
+ // the user clicked has to equal the list they are shown.
+ text = tr("(no longer in the index)");
+ }
+ if (row.messageCount >= 0) {
+ text = tr("%1 (whole thread, %n message(s))", "",
+ row.messageCount).arg(text);
+ }
+ auto *subject = new QLabel(text, content);
+ subject->setTextFormat(Qt::PlainText);
+ subject->setWordWrap(true);
+ grid->addWidget(subject, line, 0);
+ }
+
+ auto *action = new QLabel(row.action, content);
+ action->setTextFormat(Qt::PlainText);
+ grid->addWidget(action, line, 1, Qt::AlignTop | Qt::AlignRight);
+ ++line;
+ }
+
+ if (m_rows.isEmpty()) {
+ grid->addWidget(new QLabel(tr("Nothing is waiting to be synced."),
+ content),
+ 0, 0);
+ }
+
+ grid->setRowStretch(line, 1);
+
+ auto *scroll = new QScrollArea(this);
+ scroll->setWidget(content);
+ scroll->setWidgetResizable(true);
+ layout->addWidget(scroll);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ // Sized to the content rather than to a fixed guess: a handful of pending
+ // changes is the common case and a 380px box left most of itself empty.
+ // The cap is what keeps a long list scrollable instead of taller than the
+ // screen; the floor keeps the dialog from collapsing around one row.
+ //
+ // sizeHint() on the content is the whole grid's, so this asks the layout
+ // what it needs rather than multiplying a row height by a count.
+ const int wanted = content->sizeHint().height()
+ + intro->sizeHint().height()
+ + buttons->sizeHint().height() + 60;
+ resize(620, qBound(180, wanted, 560));
+}
diff --git a/src/pendingchangesdialog.h b/src/pendingchangesdialog.h
new file mode 100644
index 0000000..5e47cbd
--- /dev/null
+++ b/src/pendingchangesdialog.h
@@ -0,0 +1,86 @@
+/*
+ * 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 <QDialog>
+#include <QVector>
+
+#include "types.h"
+
+/// What one line of the dialog shows.
+///
+/// Separate from PendingChange because the two answer different questions.
+/// PendingChange is what is outstanding; this is what is drawn, and the
+/// difference is the grouping: a message with several actions contributes
+/// several rows here, only the first of which carries a subject.
+struct PendingChangeRow
+{
+ /// The subject, drawn only on the first row of a run sharing one id.
+ /// Empty on the rows beneath it, which is what puts the actions under
+ /// their message rather than beside a repeated subject.
+ QString subject;
+
+ /// What the user did. Every row has one; this is the point of the list.
+ QString action;
+
+ /// True when this row opens a new message, i.e. when `subject` is drawn.
+ /// Carried explicitly rather than inferred from a non-empty subject: a
+ /// message whose id no longer resolves has an EMPTY subject and still
+ /// opens a run of its own.
+ bool startsMessage = false;
+
+ /// How many messages a thread row covered, or -1 for a message row.
+ int messageCount = -1;
+};
+
+/// The list behind the unsynced-changes count (item 119).
+///
+/// Read-only, deliberately. This is an information window, not a place to
+/// retry or discard a change: either would be a new mutation path with its own
+/// undo question, and the count exists to answer "is my work safe to quit on"
+/// rather than to be edited.
+///
+/// A SNAPSHOT. The rows are built once, when the user opens it, and never
+/// refreshed underneath them: a dialog left open for twenty minutes shows what
+/// was true when it was opened, which is what the user clicked on.
+///
+/// Rows are exposed so the grouping can be asserted without rendering
+/// anything, which is how MessageDetailsDialog is tested and for the same
+/// reason: a pixel probe cannot tell a correct layout from a plausible one.
+class PendingChangesDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit PendingChangesDialog(const QVector<PendingChange> &changes,
+ QWidget *parent = nullptr);
+
+ /// The lines on display, in order. Exposed for testing without rendering.
+ QVector<PendingChangeRow> rows() const { return m_rows; }
+
+ /// Groups the changes into display rows: a subject on the first row of
+ /// each run sharing an id, the actions beneath it.
+ ///
+ /// Static and value-in, value-out so the grouping is testable with no
+ /// widget at all.
+ static QVector<PendingChangeRow> rowsFor(
+ const QVector<PendingChange> &changes);
+
+private:
+ QVector<PendingChangeRow> m_rows;
+};
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index fcd8e4f..f085b79 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -383,6 +383,13 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
}
case AccountLabelRole:
return QString();
+ case SenderAddressRole:
+ // The REPLY's own sender, exactly as SendersRole serves the node's
+ // `from` and not the thread's summary. The bare address is what
+ // the avatar hashes and the business-senders list matches.
+ return node.senderAddress;
+ case SenderNameRole:
+ return node.from;
case Qt::DisplayRole:
case SubjectRole:
return node.subject;
@@ -678,6 +685,23 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (!thread.recipients.isEmpty())
return thread.recipients;
return thread.authors;
+ case SenderAddressRole:
+ // The bare address of the message this card stands for, carried from
+ // the query. This is what the avatar's hash and the business-senders
+ // lookup consume; the display name below is what the card draws.
+ //
+ // The recipient first in a flat view, matching SendersRole below and
+ // for the same reason: the sender there is the user on every row, so
+ // every card would hash to one pattern. Falls back to the sender when
+ // the fold found no usable To.
+ if (!thread.firstMessageRecipient.isEmpty())
+ return thread.firstMessageRecipient;
+ return thread.firstMessageSender;
+ case SenderNameRole:
+ // The same string the card's first line shows: recipients in a flat
+ // view, where `authors` is the user on every row and says nothing.
+ return !thread.recipients.isEmpty() ? thread.recipients
+ : thread.authors;
case DateRole:
// The QDateTime itself. Formatting belongs to the delegate now: the
// card decides how much of a date it has room for, and a pre-formatted
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 2e56328..4af09d8 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -52,6 +52,15 @@ public:
/// Fill colour for that chip.
AccountColourRole,
+ /// The bare address of the message this row stands for, for the
+ /// avatar's hash and for the business-senders lookup. Empty when the
+ /// query did not resolve one, which the delegate handles by falling
+ /// back to the account.
+ SenderAddressRole,
+ /// The display name to take initials from. `authors` for a thread row,
+ /// `recipients` in a flat view, matching what the card already shows.
+ SenderNameRole,
+
/// Every tag on the thread, for the strip under the message pane.
TagsRole,
diff --git a/src/types.h b/src/types.h
index cf2411d..c78ab76 100644
--- a/src/types.h
+++ b/src/types.h
@@ -62,6 +62,25 @@ struct ThreadSummary
/// file. Do not move it behind a flag by analogy with `recipients`.
QStringList firstMessageTags;
+ /// That message's sender, as a BARE ADDRESS with no display name.
+ ///
+ /// `authors` above is notmuch's own summarised string and carries display
+ /// names ONLY: measured against the real index, 'Ryanair' and 'The Hacker
+ /// News tramite LinkedIn', with no `@` anywhere. A card therefore has no
+ /// address to hash for its avatar and nothing for the business-sender list
+ /// to match, which is why this exists (item 169).
+ ///
+ /// Hashing the display name instead was rejected: notmuch BUILDS those
+ /// strings, so one sender's identity varies as the string does.
+ ///
+ /// Free, for the same reason `firstMessageId` and `firstMessageTags` are:
+ /// the walk that finds that message is already happening and From is
+ /// served from the INDEX, not the message file. Measured 2026-08-26 on the
+ /// developer's database: 1322 distinct senders in 12 ms, 5105 messages
+ /// enumerated in 76 ms. Do not move it behind a flag by analogy with
+ /// `recipients`.
+ QString firstMessageSender;
+
/// That message's file, RELATIVE to the database path, which is what says
/// which ACCOUNT it belongs to.
///
@@ -94,6 +113,18 @@ struct ThreadSummary
/// row and carries nothing.
QString recipients;
+ /// The FIRST recipient's bare address, for the avatar in a flat view.
+ ///
+ /// Filled by the same walk as `recipients` and under the same flag, so it
+ /// costs nothing extra: the To header is already parsed there.
+ ///
+ /// A Sent or Drafts card's `firstMessageSender` is the user on every row,
+ /// so every card would carry one pattern and only the initials would vary.
+ /// The avatar answers "who is this row about", and there that is the
+ /// recipient. `firstMessageSender` stays the fallback for a row with no
+ /// usable To.
+ QString firstMessageRecipient;
+
bool isUnread() const { return tags.contains(QStringLiteral("unread")); }
bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); }
/// True when this message was forwarded. The Maildir "P" (passed) flag,
@@ -147,6 +178,13 @@ struct MessageNode
QString messageId;
QString threadId; ///< The thread this message belongs to.
QString from;
+
+ /// The BARE address of the message's From, with any display name
+ /// discarded, like `ThreadSummary::firstMessageSender` is for a thread
+ /// row. `from` above is the raw header and usually carries a display
+ /// name, which is what the avatar's hash and the business-senders
+ /// lookup cannot take initials or match against.
+ QString senderAddress;
QString subject;
QDateTime date;
QStringList tags;
@@ -226,6 +264,45 @@ struct TagChange
}
};
+/// One outstanding change, for the list behind the unsynced-changes count.
+///
+/// A SNAPSHOT taken when the user opens the list, then frozen: the count they
+/// clicked is the count the list accounts for, and a dialog left open for
+/// twenty minutes must not keep rewriting itself under them.
+///
+/// The scope follows the ACTION, never the storage. A thread action names its
+/// thread and reports how many messages it covered at snapshot time; a
+/// message action names its message. That distinction is already kept, since a
+/// held thread edit carries thread ids and everything else carries message
+/// ids, so nothing has to be expanded to reconstruct it.
+struct PendingChange
+{
+ /// The message or thread this change is about. Wire format, for resolving
+ /// a subject; never shown.
+ QString id;
+
+ /// True when `id` is a THREAD id and the change covers the conversation.
+ bool isThread = false;
+
+ /// What the user did, translated and ready to show ("Delete", "Mark
+ /// read"). Built where the change is recorded, since only there is the
+ /// direction of a tag write still known.
+ QString action;
+
+ /// The subject, filled by the resolve step. Empty until then, and left
+ /// empty for an id the index no longer holds: the row still appears, since
+ /// dropping it would make the list disagree with the count.
+ QString subject;
+
+ /// How many messages a thread change covered, at snapshot time. -1 for a
+ /// message change and for a thread whose resolve found nothing.
+ ///
+ /// At snapshot time and not at write time: a held thread edit applies when
+ /// the sync ends, and a reply landing in between makes the real number
+ /// larger. The number describes what the user is looking at.
+ int messageCount = -1;
+};
+
/// Database-level facts for the Maildir overview.
///
/// Every field is -1 until answered, so a dialog opened against a database that