diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-26 13:28:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-26 13:30:22 +0200 |
| commit | 0ca4624195cdd8c78ff614e3912af5b914458497 (patch) | |
| tree | 6fc739e77fed6b5a4e178cacf76ac29fd708ab0b /src/mainwindow.cpp | |
| parent | d835cf3c554e9657fffdb91971b66e3a74aee323 (diff) | |
| download | qtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.tar.gz qtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.zip | |
feat: flag what you answered, mark what was forwarded to you
Item 68, which turned out to be three things once its premise was
measured. The note asked to extend a "passed" subject rule to "Fw:";
there was no subject rule, and the correlation it rested on did not
exist. What did exist was a gap nobody had reported.
Reply and forward now flag their source. The Maildir R and P flags,
which every other client sets and notmuch reads back as "replied" and
"passed", had never been written here: measured on the developer's
index, all 317 "replied" and all 6 "passed" came from other clients.
ComposeWindow emits sourceMessageAnswered after a successful send and
MainWindow routes it through sendMessageTagChange, message-scoped and
off the undo stack, for the reason auto mark-read is: the flag records
that the mail went, and the send cannot be undone.
ComposeContext carries sourceMessageId rather than reusing inReplyTo,
which is deliberately empty on a forward so the recipient's client does
not file it under the thread it left. Keying on it made the "passed"
half dead code that compiled and never fired. A resumed draft is
excluded: its kind records how the file was opened, not what the user is
doing, so flagging on it would set R from a guess.
A received forward gets its own mark. Derived from the subject at paint
time, storing nothing and reaching no server, because "passed" means "I
forwarded this" and setting it from a guess would assert something false
on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s
prefix table so the two cannot disagree, strips a Re: chain first, and
takes extra locale spellings from [general] forward_prefixes, which
extends the built-in table rather than replacing it.
A mutation survived the first round and corrected a claim in the code:
QRegularExpression::escape already makes a punctuation prefix inert, so
the word guard is not about pattern validity. It stops a configured "-"
matching "-: x". The comment and test say that now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 160 |
1 files changed, 153 insertions, 7 deletions
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) { |
