diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | src/avatar.cpp | 237 | ||||
| -rw-r--r-- | src/avatar.h | 77 | ||||
| -rw-r--r-- | src/businesssenders.cpp | 168 | ||||
| -rw-r--r-- | src/businesssenders.h | 88 | ||||
| -rw-r--r-- | src/carddelegate.cpp | 78 | ||||
| -rw-r--r-- | src/carddelegate.h | 45 | ||||
| -rw-r--r-- | src/cardlayout.cpp | 10 | ||||
| -rw-r--r-- | src/cardlayout.h | 10 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 225 | ||||
| -rw-r--r-- | src/mainwindow.h | 94 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 218 | ||||
| -rw-r--r-- | src/notmuchworker.h | 37 | ||||
| -rw-r--r-- | src/pendingchangesdialog.cpp | 131 | ||||
| -rw-r--r-- | src/pendingchangesdialog.h | 86 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 24 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 9 | ||||
| -rw-r--r-- | src/types.h | 77 |
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 ¬muchConfigPath, 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 |
