diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/carddelegate.cpp | 3 | ||||
| -rw-r--r-- | src/cardlayout.cpp | 1 | ||||
| -rw-r--r-- | src/cardlayout.h | 6 | ||||
| -rw-r--r-- | src/composecontext.cpp | 52 | ||||
| -rw-r--r-- | src/composecontext.h | 20 | ||||
| -rw-r--r-- | src/composewindow.cpp | 56 | ||||
| -rw-r--r-- | src/composewindow.h | 12 | ||||
| -rw-r--r-- | src/config.cpp | 32 | ||||
| -rw-r--r-- | src/config.h | 16 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 160 | ||||
| -rw-r--r-- | src/mainwindow.h | 10 | ||||
| -rw-r--r-- | src/marks.cpp | 7 | ||||
| -rw-r--r-- | src/marks.h | 7 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 95 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 41 | ||||
| -rw-r--r-- | src/types.h | 10 |
16 files changed, 514 insertions, 14 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp index 4e27e6e..9a98c69 100644 --- a/src/carddelegate.cpp +++ b/src/carddelegate.cpp @@ -52,6 +52,8 @@ CardLayout::Input inputFor(const QModelIndex &index) in.flagged = index.data(ThreadListModel::IsFlaggedRole).toBool(); in.hasAttachment = index.data(ThreadListModel::HasAttachmentRole).toBool(); in.passed = index.data(ThreadListModel::IsPassedRole).toBool(); + in.receivedForward = + index.data(ThreadListModel::IsReceivedForwardRole).toBool(); in.replied = index.data(ThreadListModel::IsRepliedRole).toBool(); return in; } @@ -256,6 +258,7 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, drawMark(card.flagRect, Marks::Mark::Flagged); drawMark(card.attachmentRect, Marks::Mark::Attachment); drawMark(card.passedRect, Marks::Mark::Passed); + drawMark(card.receivedForwardRect, Marks::Mark::ReceivedForward); drawMark(card.repliedRect, Marks::Mark::Replied); // The reply count, which is also the expander, drawn as a PILL. diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp index 3719591..d32935b 100644 --- a/src/cardlayout.cpp +++ b/src/cardlayout.cpp @@ -260,6 +260,7 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, target = QRect(markRight - side, markTop, side, side); markRight = target.left() - kMarkGap; }; + placeMark(input.receivedForward, out.receivedForwardRect); placeMark(input.replied, out.repliedRect); placeMark(input.passed, out.passedRect); placeMark(input.hasAttachment, out.attachmentRect); diff --git a/src/cardlayout.h b/src/cardlayout.h index de4c41f..92edd96 100644 --- a/src/cardlayout.h +++ b/src/cardlayout.h @@ -68,6 +68,9 @@ struct CardLayout bool hasAttachment = false; bool passed = false; bool replied = false; + /// Someone forwarded this message TO the user (item 68). Derived from + /// the subject, so unlike the three above it corresponds to no tag. + bool receivedForward = false; }; /// Width of the account accent bar down a thread card's left edge. @@ -111,7 +114,7 @@ struct CardLayout QRect flagRect; /// The state marks after the subject, in this order: attachment, passed, - /// replied. Each is empty when its state does not apply. + /// replied, received-forward. Each is empty when its state does not apply. /// /// Separate rects rather than one strip, because each is independently /// present or absent and a strip would have to encode which. They are laid @@ -120,6 +123,7 @@ struct CardLayout QRect attachmentRect; QRect passedRect; QRect repliedRect; + QRect receivedForwardRect; /// The side of a square mark on line two, derived from the card's font so /// the marks scale with the user's text size rather than being pinned to a diff --git a/src/composecontext.cpp b/src/composecontext.cpp index 7233330..2dfee53 100644 --- a/src/composecontext.cpp +++ b/src/composecontext.cpp @@ -487,6 +487,58 @@ QString ComposeContextBuilder::forwardSubject(const QString &original) return QStringLiteral("Fwd: ") + original; } +bool ComposeContextBuilder::subjectIsForwarded(const QString &subject, + const QStringList &extraPrefixes) +{ + // Strip any Re: chain first, so "Re: Fwd: x" is recognised: a reply to a + // forward is still a forward the user received. Bounded rather than a + // while(true), since a crafted subject of ten thousand "Re:" is input from + // a stranger and this runs per row per repaint. + QString rest = subject; + for (int i = 0; i < 8; ++i) { + const QRegularExpressionMatch match = replyPrefix().match(rest); + if (!match.hasMatch()) + break; + rest = rest.mid(match.capturedEnd()); + } + + if (forwardPrefix().match(rest).hasMatch()) + return true; + + if (extraPrefixes.isEmpty()) + return false; + + // The configured spellings, matched with the same shape as the built-in + // table: anchored, case-insensitive, tolerating the counted forms Outlook + // emits. + // + // Escaped, because this comes from a hand-edited config file. Measured + // 2026-08-26: escaping alone already makes a punctuation entry inert + // rather than invalid, so the word guard below is NOT about pattern + // validity. It is about what a non-word entry would legitimately match: a + // configured "-" matches "-: x", and a digit entry matches a subject + // opening with a number, neither of which is a forward marker in any + // client. + QStringList alternatives; + for (const QString &prefix : extraPrefixes) { + const QString trimmed = prefix.trimmed(); + // A word only. A configured "Re" would swallow every reply, and a + // configured ":" or "" would match every subject in the mailbox. + static const QRegularExpression word(QStringLiteral("^[^\\W\\d_]+$")); + if (trimmed.isEmpty() || !word.match(trimmed).hasMatch()) + continue; + alternatives << QRegularExpression::escape(trimmed); + } + if (alternatives.isEmpty()) + return false; + + const QRegularExpression extra( + QStringLiteral("^\\s*(%1)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:") + .arg(alternatives.join(QLatin1Char('|'))), + QRegularExpression::CaseInsensitiveOption); + return extra.match(rest).hasMatch(); +} + ComposeContext ComposeContextBuilder::forDraft(const Config &config, const QString &path) { diff --git a/src/composecontext.h b/src/composecontext.h index 6f311c7..b94d0f8 100644 --- a/src/composecontext.h +++ b/src/composecontext.h @@ -168,6 +168,26 @@ QString replySubject(const QString &original); QString forwardSubject(const QString &original); +/// True when \p subject reads as a message someone forwarded TO the user. +/// +/// Item 68. This is a DISPLAY predicate: the card draws a mark from it and +/// stores nothing. It must never write a tag or a Maildir flag, because +/// `passed` (the `P` flag) means "I forwarded this" and is a different fact +/// about a different person. Setting it from a subject guess would assert +/// something false and, with maildir.synchronize_flags on, propagate that to +/// the server. +/// +/// Matches the same prefix table forwardSubject() uses, so the set of +/// recognised spellings cannot drift between "do not double the prefix" and +/// "this is a forward". \p extraPrefixes adds locale spellings the built-in +/// table omits, from `[general] forward_prefixes`; each is a bare word without +/// its colon ("doorst", "vs"). An entry that is empty or not a word is ignored. +/// +/// A `Re:` chain is stripped first, so `Re: Fwd: x` is recognised: a reply to +/// a forward is still a forward the user received. +bool subjectIsForwarded(const QString &subject, + const QStringList &extraPrefixes = {}); + /// Builds the context that RESUMES a draft from its file. /// /// Unlike a reply, nothing here is derived: the recipients, the subject and diff --git a/src/composewindow.cpp b/src/composewindow.cpp index 879a9d1..afcf6a2 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -21,6 +21,7 @@ #include <QTemporaryDir> #include "draftstore.h" +#include "maildirname.h" #include "messagebuilder.h" #include "mimeparser.h" #include "messagesender.h" @@ -1418,11 +1419,62 @@ void ComposeWindow::send() dialog->setStage(SendDialog::Stage::RemovingDraft); if (!m_draftPath.isEmpty()) { - QFile::remove(m_draftPath); - emit draftRemoved(m_draftPath); + // Re-resolved, because mbsync renames an uploaded draft to add + // its `,U=<uid>` infix while m_draftPath still holds the name + // DraftStore::write() returned. Without this the remove is a + // silent no-op on a path that no longer exists: measured + // 2026-08-26 on the user's own mail, where a forwarded message + // was sent and filed correctly and its draft stayed in the + // Drafts view carrying the `D` flag. + // + // Item 163 added resolveRenamed() and wired it into the three + // READ sites (the pane, Reply/Forward, the draft reopen). This + // is the write site, and it was missed: the same rename, the + // same fix, one call site later. + // + // The unresolved path is emitted when nothing matches, so a + // draft that genuinely vanished still asks the worker to drop + // its index entry rather than leaving a ghost. + const QString actual = MaildirName::resolveRenamed(m_draftPath); + const QString target = actual.isEmpty() ? m_draftPath : actual; + QFile::remove(target); + emit draftRemoved(target); m_draftPath.clear(); } + // Item 68. The Maildir R and P flags, recorded on the message this + // one answers. Both were measured missing on 2026-08-26: every one + // of the 317 `replied` and 6 `passed` in the developer's own index + // came from another client, because nothing here has ever written + // either. + // + // AFTER the send, never before: the flag asserts that the mail + // went, and an abandoned composer must leave no trace on the + // message it was answering. + // + // sourceMessageId, NOT inReplyTo: that header is deliberately + // empty on a Forward, so keying on it would have made the `passed` + // half dead code that compiles and never fires. + // + // A resumed Draft is deliberately excluded even when it carries a + // source id. Its kind records how the FILE was opened, not what the + // user is doing, so a draft that began as a reply cannot be told + // from one that began as a new message; flagging on that would set + // R from a guess. The cost is a missing flag on a reply finished in + // two sittings, which is the safe direction: maildir.synchronize_- + // flags is on, so a wrong flag reaches the server. + if (!m_context.sourceMessageId.isEmpty()) { + QString tag; + if (m_context.kind == ComposeContext::Kind::Reply + || m_context.kind == ComposeContext::Kind::ReplyAll) { + tag = QStringLiteral("replied"); + } else if (m_context.kind == ComposeContext::Kind::Forward) { + tag = QStringLiteral("passed"); + } + if (!tag.isEmpty()) + emit sourceMessageAnswered(m_context.sourceMessageId, tag); + } + dialog->accept(); dialog->deleteLater(); diff --git a/src/composewindow.h b/src/composewindow.h index 2eaeeea..c8cc12a 100644 --- a/src/composewindow.h +++ b/src/composewindow.h @@ -189,6 +189,18 @@ signals: /// \p path is the file that was removed, absolute. void draftRemoved(const QString &path); + /// A send succeeded, and the message it answers should record that. + /// + /// Item 68. \p sourceMessageId is the Message-ID of the message replied to + /// or forwarded, \p tag is "replied" or "passed". The window emits rather + /// than writing, because a tag write belongs to the one applyTags path in + /// MainWindow and the composer owns no worker. + /// + /// Emitted only after the send itself succeeded: a failed send leaves the + /// source untouched, since the flag asserts that the mail went. + void sourceMessageAnswered(const QString &sourceMessageId, + const QString &tag); + protected: /// The one place the registry is told, whichever route closes the window. void closeEvent(QCloseEvent *event) override; diff --git a/src/config.cpp b/src/config.cpp index d91259a..8b784ba 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -170,6 +170,13 @@ QString Account::inboxQuery() const return folderQuery(maildir, inboxFolder()); } +QString Config::generatorTagFor(const QString &generator) +{ + // Delegates to the file-local table rather than repeating it, so the + // question "which tag does this filter match" has one answer. + return generatorTag(generator); +} + QString Config::allSentQuery() const { return joinAccountQueries(m_accounts, &Account::sentQuery); @@ -338,6 +345,31 @@ void Config::load(const QString &path) } } + // Extra subject prefixes that mark a message someone forwarded TO the + // user, added to the built-in table in composecontext.cpp rather than + // replacing it: the built-ins are the spellings that repo already + // measured, and a user adding Dutch should not have to restate English. + // + // Bare words, no colon. The predicate ignores anything else, so a + // malformed entry costs that entry and not the whole key. + const QStringList forwardPrefixes = + settings.value(QStringLiteral("forward_prefixes")) + .toStringList(); + for (const QString &prefix : forwardPrefixes) { + const QString trimmed = prefix.trimmed(); + if (trimmed.isEmpty()) + continue; + // Warned rather than dropped silently: the user asked for something + // and is not getting it, the same reason message_zoom warns. + if (trimmed.contains(QLatin1Char(':'))) { + addProblem(tr("Forward prefix '%1' should be written without " + "its colon; ignoring it.") + .arg(trimmed)); + continue; + } + m_forwardPrefixes << trimmed; + } + // Absent is silent, the default being 2000. Present but unparseable warns, // for the same reason message_zoom does: the user asked for something and // is not getting it. diff --git a/src/config.h b/src/config.h index 02b4038..26fc1c1 100644 --- a/src/config.h +++ b/src/config.h @@ -349,6 +349,14 @@ public: /// account that configures no sent folder would show the entire Maildir. static QString matchNothingQuery(); + /// The tag a built-in generator matches, or empty for the folder-backed + /// ones (`sent`, `drafts`, `trash`) which compose from a path instead. + /// + /// Exposed so a caller asking "which tag decides membership of this view" + /// reads the same table the query generator does, rather than keeping a + /// second copy that can drift from it. + static QString generatorTagFor(const QString &generator); + /// Empty when unset; the caller disables the Sync button in that case. QString syncCommand() const { return m_syncCommand; } @@ -412,6 +420,13 @@ public: /// the same fixed string on every card rather than failing visibly. QString dateFormat() const { return m_dateFormat; } + /// Extra subject prefixes marking a forward the user RECEIVED (item 68). + /// + /// Added to the built-in table in composecontext.cpp, never replacing it, + /// so a user adding a locale keeps the measured English/German/Iberian/ + /// French spellings. Bare words, no colon. + QStringList forwardPrefixes() const { return m_forwardPrefixes; } + /// Interface language, or empty to follow the environment. /// /// A locale name, short ("it") or full ("it_IT"); Qt resolves the short @@ -536,6 +551,7 @@ private: int m_toolbarIconSize = 24; QString m_notmuchConfig; QString m_dateFormat; + QStringList m_forwardPrefixes; QString m_language; qreal m_messageZoom = 1.0; bool m_completionOnFocus = false; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 89c01eb..9166588 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -825,6 +825,7 @@ void MainWindow::buildUi() m_model = new ThreadListModel(this); m_model->setTagColors(&m_tagColors); m_model->setDateFormat(m_config.dateFormat()); + m_model->setForwardPrefixes(m_config.forwardPrefixes()); // ThreadListView, not a plain QTableView: it paints the row-wide tag // strip under each row's cells, which no delegate can do because a // delegate is confined to one column's rectangle. @@ -1071,6 +1072,11 @@ void MainWindow::openComposerFor(const MessageRef &ref, context.kind = kind; context.originalPath = originalPath; + // Item 68. Set for a forward as well as a reply, which is why it is not + // inReplyTo: that header is deliberately omitted from a forward below, and + // the P flag still belongs on the message that was forwarded. + context.sourceMessageId = original.messageId; + const bool replyAll = kind == ComposeContext::Kind::ReplyAll; const bool forwarding = kind == ComposeContext::Kind::Forward; @@ -1166,6 +1172,26 @@ void MainWindow::openComposer(const ComposeContext &context) connect(composer, &ComposeWindow::draftRemoved, m_worker, &NotmuchWorker::removeIndexedFile); + // Item 68. The R and P Maildir flags, on the message the send answered. + // + // sendMessageTagChange, NOT tagSelected: this deliberately does not go on + // the undo stack, for the reason auto mark-read does not (see + // markCurrentThreadRead). The flag records a fact the user brought about + // by sending, and the send itself cannot be undone, so offering Ctrl+Z to + // retract only the flag would leave the two disagreeing. Removing the tag + // by hand still works. + // + // Message-scoped: the message answered, never its thread. + connect(composer, &ComposeWindow::sourceMessageAnswered, this, + [this](const QString &messageId, const QString &tag) { + if (messageId.isEmpty() || tag.isEmpty()) + return; + sendMessageTagChange({ messageId }, { tag }, {}, + tag == QStringLiteral("passed") + ? tr("Mark forwarded") + : tr("Mark replied")); + }); + composer->show(); } @@ -1555,9 +1581,23 @@ void MainWindow::registerActions() }); addAction(QStringLiteral("delete"), tr("&Delete"), tr("Add or remove the deleted tag"), [this]() { - // A toggle, like toggle_unread: pressing Delete twice is the natural - // way to say "no, put it back", and adding a tag that is already there - // is a no-op the user cannot see. + // Two directions, and since 2026-08-26 only ONE of them is reachable + // on ordinary mail. + // + // This began as item 16's toggle: pressing Delete twice was how a user + // said "no, put it back", and it existed because the deleted row + // STAYED in the view, tinted, with nothing else to press. Delete now + // strips `inbox` and the row leaves the view immediately, so there is + // no second press to make and the mitigation is not needed; Ctrl+Z + // retracts, and Restore in the trash is the deliberate route. + // + // The undelete branch survives because it is NOT dead: stranded mail + // (tagged `deleted`, outside any trash folder, from a version before + // Delete moved files) is the one place `allDeleted` is still true + // where Delete is visible at all, since item 168 hides the action + // whenever every selected row is already in a trash folder. That is + // what `cleanup_stranded` sends the user to, telling them to select + // what should go and press Delete. // // One direction for the WHOLE selection. Toggling each thread // independently would leave one keystroke with the selection in two @@ -3272,6 +3312,28 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope) QString query = m_queryEdit->text().trimmed(); + // Whether this is the trash view, which suppresses the doomed fill: every + // row there is deleted, so the crimson says nothing and only costs + // legibility. + // + // Derived from the QUERY rather than from which button was clicked, so a + // hand-typed or edited trash query gets the same treatment as the button, + // and set on EVERY run for the reason flat mode is: a flag left standing + // would paint the next view's genuinely doomed rows plain. + // + // Compared against the trash filter resolved in the CURRENT account scope, + // which is what runFilter() put in the bar. matchNothingQuery() is + // excluded because it is a real string that compares equal to itself, so + // an account with no trash folder would otherwise match it. + { + const QString trashQuery = m_config.resolvedQuery( + Config::builtinFilter(QStringLiteral("trash")), + m_accountBox->currentData().toString()); + m_model->setTrashView(!query.isEmpty() + && trashQuery != Config::matchNothingQuery() + && query == trashQuery); + } + // A built-in filter arrives already resolved in the selected account's // scope, because a generator has to be asked for the account's own query // rather than have its all-accounts query wrapped. Scoping again here would @@ -5459,9 +5521,15 @@ void MainWindow::trashMessages(const QStringList &messageIds, // to touch, and the difference is who is acting: the hook tags // arriving mail unattended, while this is an explicit gesture on a // message in front of the user. + // `inbox` goes with it too. Without that a message deleted FROM the + // inbox keeps the tag the Inbox filter matches on, so it stays in that + // view after being thrown away: measured 2026-08-26 on the user's own + // mail, where it was the only message ever deleted from an inbox and + // therefore the only one that could show it. Restore does not depend + // on it surviving, since `deleted-from:` carries the origin. sendMove(it.value(), it.key(), { QStringLiteral("deleted"), kOriginTagPlaceholder() }, - { QStringLiteral("unread") }, + { QStringLiteral("unread"), QStringLiteral("inbox") }, tr("Delete"), false, wholeThreadIds); } @@ -5745,12 +5813,40 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds, QStringList remove{ QStringLiteral("deleted") }; if (!origin.isEmpty()) remove.append(origin); - sendMove(it.value(), it.key(), {}, remove, tr("Restore")); + + // `inbox` comes back when, and only when, the message is going back + // to an inbox. Delete strips it (so a deleted message leaves the + // Inbox view), which makes restoring it the other half of that + // change: without this a restored message sits in the inbox FOLDER + // carrying no `inbox` TAG, invisible to the view it was returned to + // until the next hook run. Undo is unaffected either way, since + // TagChange::inverted() gives back exactly what the move removed. + // + // Judged on the DESTINATION folder rather than on the origin tag's + // text, so an account whose inbox is named something else is right for + // the same reason inboxFolderFor() exists. The key is + // `<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. + QStringList add; + const QString destMaildir = it.key().section(QLatin1Char('/'), 0, 0); + for (const Account &candidate : m_config.accounts()) { + if (candidate.maildir != destMaildir) + continue; + if (origin.compare(candidate.inboxFolder(), Qt::CaseInsensitive) == 0) + add.append(QStringLiteral("inbox")); + break; + } + + sendMove(it.value(), it.key(), add, remove, tr("Restore")); } for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) { - sendMove(it.value(), it.key(), {}, { QStringLiteral("deleted") }, - tr("Restore")); + // This branch IS the inbox by construction: it is the fallback for a + // message with no origin tag, and the folder it names is the + // account's own inbox. So the tag always comes with it. + sendMove(it.value(), it.key(), { QStringLiteral("inbox") }, + { QStringLiteral("deleted") }, tr("Restore")); } if (!byInbox.isEmpty()) { @@ -6092,6 +6188,24 @@ void MainWindow::sendMove(const QStringList &messageIds, m_model->applyMessageTagChange(messageId, displayAdd, displayRemove); } + // A row that no longer belongs in the view LEAVES it, rather than sitting + // there repainted until the next query. Delete strips `inbox`, so in the + // Inbox view the message it stripped it from stops matching, and leaving + // it was the defect: a deleted message stayed in the inbox across + // restarts, since the tag really was gone from the display and really was + // still what the query asked for. + // + // Guarded on the VIEW's own tag, resolved from the query rather than + // assumed: a plain `tag:<x>` query is the only shape whose membership one + // tag decides. A path query (Trash, Sent, Drafts) is unaffected by a tag + // going away, and an arbitrary query the user typed cannot be reasoned + // about at all, so both are left alone and refresh at the next sync. + // Without that guard, deleting from an `id:` view would empty the list. + if (const QString viewTag = viewFilterTag(); + !viewTag.isEmpty() && displayRemove.contains(viewTag)) { + m_model->removeThreadsWithoutTag(viewTag); + } + // What to tag once the move is CONFIRMED. Tagging now would leave a // message marked deleted in a folder it never left if the rename failed. // @@ -6110,6 +6224,38 @@ void MainWindow::sendMove(const QStringList &messageIds, Q_ARG(QString, destFolder)); } +QString MainWindow::viewFilterTag() const +{ + const QString query = m_queryEdit->text().trimmed(); + if (query.isEmpty()) + return {}; + + const QString accountKey = m_accountBox->currentData().toString(); + + // The three tag-backed built-ins, matched against the query RESOLVED in + // the current account scope, which is what runFilter() put in the bar. The + // comparison is on the generated string rather than on the button's + // checked state, so a query the user edited by hand into the same thing + // behaves identically, and a label translated into another locale cannot + // change the answer. + for (const QString &generator : { QStringLiteral("unread"), + QStringLiteral("inbox"), + QStringLiteral("flagged") }) { + const SavedQuery filter = Config::builtinFilter(generator); + const QString resolved = m_config.resolvedQuery(filter, accountKey); + if (resolved == Config::matchNothingQuery()) + continue; + if (resolved == query) { + // The TAG, not the generator: "flagged" happens to match its tag + // and "inbox" and "unread" do too, but the generator is a filter + // identity and the tag is what a message carries. + return Config::generatorTagFor(generator); + } + } + + return {}; +} + void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId, const QString &destFolder) { diff --git a/src/mainwindow.h b/src/mainwindow.h index a5a8c31..cb8cec4 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1104,6 +1104,16 @@ private: /// Confirms a move: applies the tags the move was asked to carry, with the /// origin placeholder resolved per message. + /// The single tag the CURRENT view's membership depends on, or empty. + /// + /// Only the three plain `tag:` filters (Unread, Inbox, Important) have + /// one: their query is exactly that tag, so a message losing it stops + /// belonging. Trash, Sent and Drafts are PATH queries, where a tag change + /// decides nothing, and a hand-typed query is not reasoned about at all. + /// Both answer empty, which is what keeps an optimistic row removal from + /// firing in a view it cannot judge. + QString viewFilterTag() const; + void onMessagesMoved(const QMap<QString, QString> &originByMessageId, const QString &destFolder); diff --git a/src/marks.cpp b/src/marks.cpp index 8777f84..d205ea3 100644 --- a/src/marks.cpp +++ b/src/marks.cpp @@ -57,6 +57,13 @@ QByteArray svg(Mark mark) "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 6.8,2.2 V 5.0 " "H 8.7 C 11.9,5.0 14.2,7.4 14.2,10.8 V 13.8 a 0.9,0.9 0 0 1 -1.75,0.28 C " "11.8,12.1 10.4,10.9 8.7,10.9 H 6.8 V 13.7 L 1.0,7.95 Z\"/> </svg>"); + case Mark::ReceivedForward: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 2.0,2.6 a " + "0.95,0.95 0 0 1 1.9,0 V 6.4 C 3.9,8.1 5.2,9.4 6.9,9.4 H 9.6 V 6.6 L " + "15.0,11.0 L 9.6,15.4 V 12.6 H 6.9 C 3.5,12.6 2.0,10.3 2.0,7.4 Z\"/> " + "</svg>"); case Mark::ExpanderCollapsed: return QByteArray( "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " diff --git a/src/marks.h b/src/marks.h index 823c129..e266924 100644 --- a/src/marks.h +++ b/src/marks.h @@ -52,6 +52,13 @@ enum class Mark { Flagged, Passed, Replied, + /// Someone forwarded this message TO the user. + /// + /// Not a Maildir flag and not a tag: derived from the subject line at paint + /// time, so it stores nothing and reaches no server. Passed means "I + /// forwarded this", which is a different fact about a different person, + /// which is why this is its own mark rather than a reuse. + ReceivedForward, ExpanderCollapsed, ExpanderExpanded, }; diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 6162a5f..fcd8e4f 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -18,6 +18,8 @@ #include "threadlistmodel.h" +#include "composecontext.h" + #include <QSet> #include <QBrush> @@ -177,6 +179,65 @@ void ThreadListModel::setFlatMode(bool flat) endResetModel(); } +void ThreadListModel::setTrashView(bool trash) +{ + if (m_trashView == trash) + return; + + m_trashView = trash; + + // A repaint, NOT a reset: this changes two colour roles and nothing about + // the shape of the tree, so unlike setFlatMode() there are no child rows + // to invalidate and a reset would collapse every expanded thread for a + // change of paint. Emitted over the whole list including children, since + // the message-row branch reads the same flag. + if (m_threads.isEmpty()) + return; + const QVector<int> roles{ Qt::BackgroundRole, Qt::ForegroundRole }; + emit dataChanged(index(0, 0, QModelIndex()), + index(m_threads.size() - 1, 0, QModelIndex()), roles); + for (int row = 0; row < m_threads.size(); ++row) { + const QModelIndex parent = index(row, 0, QModelIndex()); + const int children = rowCount(parent); + if (children > 0) { + emit dataChanged(index(0, 0, parent), + index(children - 1, 0, parent), roles); + } + } +} + +void ThreadListModel::removeThreadsWithoutTag(const QString &tag) +{ + if (tag.isEmpty() || m_threads.isEmpty()) + return; + + // The tags a row is judged on are the ones its CARD draws: the loaded + // message's own when there is one, the thread's union otherwise. That is + // the same substitution data() makes for a thread row, and using the + // summary alone would keep a row whose displayed message lost the tag + // while a sibling still carries it. + const auto keeps = [&tag](const ThreadNode &node) { + if (!node.first.messageId.isEmpty()) + return node.first.tags.contains(tag); + return node.summary.tags.contains(tag); + }; + + // Backwards, in contiguous runs, exactly as reconcile() removes: each + // beginRemoveRows renumbers everything after it, so walking forwards + // removes the wrong rows after the first deletion. + for (int row = m_threads.size() - 1; row >= 0; --row) { + if (keeps(m_threads.at(row))) + continue; + int first = row; + while (first > 0 && !keeps(m_threads.at(first - 1))) + --first; + beginRemoveRows({}, first, row); + m_threads.remove(first, row - first + 1); + endRemoveRows(); + row = first; + } +} + int ThreadListModel::rowCount(const QModelIndex &parent) const { if (!parent.isValid()) @@ -338,6 +399,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return node.isFlagged(); case IsPassedRole: return node.isPassed(); + case IsReceivedForwardRole: + return ComposeContextBuilder::subjectIsForwarded(node.subject, + m_forwardPrefixes); case IsRepliedRole: return node.isReplied(); case ReplyCountRole: @@ -352,9 +416,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // thread row does. Without this branch a message-scoped Delete // repainted a reply identically to an undeleted one, so the // pending count moved and nothing on screen did. - if (node.isDoomed()) + // Suppressed in the trash view, exactly as on a thread row: see + // the comment there. Both branches must agree, or an expanded + // thread in the trash paints its replies crimson under an + // untinted root. + if (node.isDoomed() + && !(m_trashView && node.isDeleted() && !node.isSpam())) { return QBrush(node.isDeleted() ? deletedColour() : spamColour()); + } // Tinted, so an expanded thread reads as one block rather than as // more table rows. Applied per cell here; ThreadListView fills the @@ -401,8 +471,16 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // read colour is mixed toward the BACKGROUND, so leaving it here // would compute a grey against the pane's base and then paint it // over red. - if (node.isDoomed()) + // + // Tied to the FILL, not to isDoomed(): where the fill is + // suppressed in the trash view there is no red to sit on, and + // white text would land on the ordinary background unreadable. + // The strike-out below is deliberately NOT suppressed, since it + // is the cue that survives without colour at all. + if (node.isDoomed() + && !(m_trashView && node.isDeleted() && !node.isSpam())) { return QBrush(QColor(Qt::white)); + } // Dimmed whether read or not, for the same reason as the font: a // reply is subordinate content. An unread one is left undimmed so @@ -611,6 +689,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return thread.isFlagged(); case IsPassedRole: return thread.isPassed(); + case IsReceivedForwardRole: + return ComposeContextBuilder::subjectIsForwarded(thread.subject, + m_forwardPrefixes); case IsRepliedRole: return thread.isReplied(); case ReplyCountRole: @@ -633,7 +714,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // whole row: a cue on a single column disappears as soon as that column // scrolls out of view, which is exactly how the tag change used to go // unnoticed. - if (thread.isDoomed()) { + // + // In the TRASH view the deleted fill is suppressed: every row there is + // deleted, so a list painted entirely crimson tells the user nothing they + // did not ask for by opening the trash, and costs the legibility the fill + // borrows. Only `deleted` is suppressed; a SPAM row keeps its tint, since + // "this is junk" is still news in a folder that only promises "this is + // thrown away". + const bool suppressed = m_trashView && thread.isDeleted() && !thread.isSpam(); + if (thread.isDoomed() && !suppressed) { if (role == Qt::BackgroundRole) return QBrush(thread.isDeleted() ? deletedColour() : spamColour()); if (role == Qt::ForegroundRole) diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 717537c..2e56328 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -135,6 +135,12 @@ public: /// Item 69 draws this as a mark where it used to read as the word /// "passed" in the tag strip. IsPassedRole, + /// True when the SUBJECT reads as a forward someone sent the user. + /// + /// Item 68. Derived from the subject at query time, not from a tag or + /// a Maildir flag: `passed` means "I forwarded this", which is a + /// different fact. Nothing is stored and nothing reaches the server. + IsReceivedForwardRole, /// bool; the message was replied to, from the Maildir "R" flag. IsRepliedRole, @@ -187,6 +193,16 @@ public: /// The pattern DateFormatRole answers with. Empty means the system format. void setDateFormat(const QString &format) { m_dateFormat = format; } + /// Extra subject prefixes counting as a received forward (item 68). + /// + /// Pushed in from the config exactly as setDateFormat() is, rather than + /// giving the model a Config: both are display values the window already + /// holds, and the model draws rather than resolves. + void setForwardPrefixes(const QStringList &prefixes) + { + m_forwardPrefixes = prefixes; + } + /// One row per thread, with no expander and no reply count. /// /// For the Sent view, where a thread is the wrong unit: the user's model of @@ -203,6 +219,29 @@ public: /// The children are not discarded, only hidden. Leaving flat mode restores /// the tree without reloading anything. void setFlatMode(bool flat); + + /// Whether the list is showing the trash view. + /// + /// The doomed fill exists to tell the user a message is on its way out of + /// a view it is still sitting in. In the trash that is redundant: every + /// row is deleted, and a list painted entirely crimson says nothing while + /// costing legibility. Set on EVERY query run, like flat mode, so it + /// cannot leak into the next view. + void setTrashView(bool trash); + + /// Drops any top-level row whose message no longer carries \p tag. + /// + /// The optimistic counterpart to a row simply vanishing at the next query. + /// Delete strips `inbox`, and in the Inbox view the row it stripped it + /// from stops belonging there; leaving it until the next sync is what made + /// a deleted message sit in the inbox looking undeleted. + /// + /// Top-level rows ONLY, and deliberately: a reply that no longer matches + /// still belongs to the conversation the user has open, and removing it + /// would collapse a thread under the reader's hands. \p tag is the tag the + /// CURRENT VIEW requires, so a caller passes what the query filters on and + /// nothing else. + void removeThreadsWithoutTag(const QString &tag); bool flatMode() const { return m_flatMode; } QModelIndex index(int row, int column, @@ -414,5 +453,7 @@ private: QVector<ThreadNode> m_threads; const TagColors *m_tagColors = nullptr; QString m_dateFormat; + QStringList m_forwardPrefixes; bool m_flatMode = false; + bool m_trashView = false; }; diff --git a/src/types.h b/src/types.h index 7464586..cf2411d 100644 --- a/src/types.h +++ b/src/types.h @@ -252,7 +252,15 @@ struct ComposeContext QString accountKey; ///< Which account sends. Plain data here; the resolution rules live with whatever builds this context. Kind kind = Kind::New; QString originalPath; ///< The .eml being replied to or forwarded. Empty for New. - QString inReplyTo; ///< Message-ID of the original. + QString inReplyTo; ///< Message-ID of the original. EMPTY for a Forward: carrying In-Reply-To would file the forward under the thread it left, in the recipient's client. + + /// Message-ID of the message being answered, for flagging it afterwards. + /// + /// Item 68. Separate from inReplyTo because that is a THREADING header and + /// is deliberately empty on a Forward, while the P flag still has to land + /// on the message that was forwarded. Set for Reply, ReplyAll and Forward; + /// empty for New and for a resumed Draft. + QString sourceMessageId; QStringList references; ///< The original's References plus its Message-ID. QStringList to; ///< Pre-filled, the user's own addresses already stripped. QStringList cc; |
