diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/keymap.cpp | 6 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 102 | ||||
| -rw-r--r-- | src/mainwindow.h | 16 |
3 files changed, 122 insertions, 2 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp index b7cf8f5..098a85d 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -41,6 +41,12 @@ QStringList KeyMap::knownActions() // carries no default binding for exactly the same reason: the act is // identical and so is the hazard. QStringLiteral("purge"), + // Empty trash's other sibling, per ACCOUNT: it MOVES every message in + // the spam folder to that account's trash, so it is undoable and + // carries no confirmation. No default binding either: a bulk move + // deserves a deliberate gesture, and since item 132 an unbound action + // is menu-reachable rather than broken. + QStringLiteral("empty_spam"), QStringLiteral("spam"), QStringLiteral("toggle_unread"), QStringLiteral("mark_all_read"), diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 4a4a8d1..9198839 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1783,6 +1783,16 @@ void MainWindow::registerActions() tr("Permanently delete the selected messages"), [this]() { purgeSelected(); }); + // Empty Trash's other sibling, but a MOVE rather than a purge: it moves + // every message in the spam folder to that account's trash, so it is + // undoable and asks nothing. The mnemonic is on "f&older" rather than the + // brief's `s&pam` because Alt+P is already Re&ply and Alt+S is Mark &spam, + // and no letter of "Empty spam" is free in this menu. + addAction(QStringLiteral("empty_spam"), tr("Empty spam f&older..."), + tr("Move every message in the spam folder to the trash"), + [this]() { + emptySpam(); + }); addAction(QStringLiteral("spam"), tr("Mark &spam"), tr("Move the selected messages to the spam folder"), [this]() { spamSelected(); @@ -2072,6 +2082,13 @@ const QHash<QString, QPair<QString, QString>> kThemeIcons = { // different amounts of mail must not look identical. `user-trash` is // the theme's own wastebasket, which reads as "this one, gone". { QStringLiteral("purge"), { QStringLiteral("user-trash"), QString() } }, + // Empty Spam SHARES purge's `user-trash`, deliberately: both move mail + // into the trash, and the standard wastebasket is the honest glyph. The + // no-duplicates rule is about the icon-only TOOLBAR, and this is a + // Message-menu-only entry that always carries its text, so it is named in + // noTwoActionsShareAnIcon()'s exception list. Putting it on the toolbar + // fails that test rather than passing silently. + { QStringLiteral("empty_spam"), { QStringLiteral("user-trash"), QString() } }, { QStringLiteral("undo"), { QStringLiteral("edit-undo"), QString() } }, // `bug` first, per the user's choice, with the standard junk name behind // it so a theme without the bug still draws a junk icon. @@ -2197,6 +2214,7 @@ void MainWindow::buildMenus() // the five filters would read as one of them. messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded"))); messageMenu->addAction(m_actions.value(QStringLiteral("empty_trash"))); + messageMenu->addAction(m_actions.value(QStringLiteral("empty_spam"))); messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules"))); auto *viewMenu = menuBar()->addMenu(tr("&View")); @@ -3718,6 +3736,28 @@ bool MainWindow::isShowingTrash() const return false; } +bool MainWindow::isShowingSpam() const +{ + // Exact sibling of isShowingTrash(), and path-based for the same reason: + // the Spam filter matches a folder rather than a tag, so a message put + // there by another client is in the view and carries no tag of ours. Both + // scopes, because the view composes with the account dropdown. + const QString query = m_lastQuery.trimmed(); + if (query.isEmpty()) + return false; + + const QString all = m_config.allSpamQuery().trimmed(); + if (!all.isEmpty() && query == all) + return true; + + for (const Account &account : m_config.accounts()) { + const QString spam = account.spamQuery().trimmed(); + if (!spam.isEmpty() && query == spam) + return true; + } + return false; +} + void MainWindow::updateViewWideActions() { // Only meaningful on mail that is actually in a trash folder. An enabled @@ -6576,6 +6616,31 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, return; } + if (requestTag == QStringLiteral("empty_spam")) { + // Grouped per account, because each group travels to that account's + // own trash: one destination composed once would put one account's + // junk in another's trash. The origin tag is the placeholder, resolved + // per message by onMessagesMoved() to the spam folder it is leaving; + // Task 3's overwrite rule strips the previous origin, so a message + // that travelled inbox -> spam -> trash keeps exactly one. + QHash<QString, QStringList> byTrash; + for (int i = 0; i < messageIds.size(); ++i) { + const Account account = accountForMessagePath(paths.at(i)); + if (account.maildir.isEmpty() || account.trash.isEmpty()) + continue; + byTrash[account.maildir + QLatin1Char('/') + account.trash] + .append(messageIds.at(i)); + } + for (auto it = byTrash.cbegin(); it != byTrash.cend(); ++it) { + sendMove(it.value(), it.key(), + { QStringLiteral("deleted"), kOriginTagPlaceholder() }, + { QStringLiteral("spam"), QStringLiteral("unread"), + QStringLiteral("inbox") }, + tr("Empty spam")); + } + return; + } + if (requestTag == QStringLiteral("purge_selection")) { // No scope named: the prompt says "the selection", which is what the // user pointed at, rather than an account they did not. @@ -6899,6 +6964,37 @@ void MainWindow::emptyTrash() Q_ARG(QString, QStringLiteral("empty_trash"))); } +void MainWindow::emptySpam() +{ + // Scoped to the account selector, like emptyTrash(): All accounts empties + // every configured spam folder, a selected account only its own. Unlike + // emptyTrash() there is no confirmation, because this is a MOVE and every + // mutation that can be undone gets undo instead of a dialog. + const QString accountKey = m_accountBox->currentData().toString(); + const QString query = accountKey.isEmpty() + ? m_config.allSpamQuery() + : m_config.account(accountKey).spamQuery(); + + // An account with no spam folder configured produces an EMPTY query, and + // an empty notmuch query matches EVERYTHING. Refusing here rather than + // relying on the worker's guard is the whole safety of the action: the + // message names the cause. + if (query.isEmpty()) { + showTransientStatus(tr("No spam folder is configured")); + return; + } + + if (!m_worker) { + showTransientStatus(tr("Not connected to the mail index")); + return; + } + + QMetaObject::invokeMethod(m_worker, "resolveQueryMessages", + Qt::QueuedConnection, + Q_ARG(QString, query), + Q_ARG(QString, QStringLiteral("empty_spam"))); +} + void MainWindow::purgeSelected() { const QModelIndexList rows = @@ -7488,8 +7584,10 @@ void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId // // Gated on isShowingTrash() and not on the destination: a Delete is a move // too and reaches this same slot, and refreshing after every delete would - // make a row vanish from under the user in every other view. - if (isShowingTrash()) + // make a row vanish from under the user in every other view. The Spam view + // is the same case: Empty Spam is path-based, so moved rows stop matching + // and only a refresh can say so. + if (isShowingTrash() || isShowingSpam()) refreshCurrentQuery(); // The undo entries are pushed inside the loop above, one per origin diff --git a/src/mainwindow.h b/src/mainwindow.h index 1fc8822..ab65f1d 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1321,6 +1321,17 @@ private: /// which holds whatever the current view happens to show. void emptyTrash(); + /// Moves every message in the spam folder to that account's trash. Unlike + /// emptyTrash() this is NOT a purge: it is a move, so it is undoable and + /// asks nothing. Scoped to the account selector like every other + /// account-aware surface, and grouped per account because the destination + /// differs: one account's spam moves to that account's own trash. + /// + /// Asynchronous for the same reason emptyTrash() is, but no confirm follows: + /// the answer arrives at onThreadMessagesResolved() tagged `empty_spam`, + /// which resolves the destinations from each message's own path. + void emptySpam(); + /// Empty trash's sibling, scoped to the SELECTION rather than to the /// account's whole trash. The act is identical, `purgeMessages()` in both /// cases, and so are its safeguards: it confirms, and it carries no @@ -1362,6 +1373,11 @@ private: /// in it, and such a message carries no tag of ours. bool isShowingTrash() const; + /// Whether the current query IS a spam view, for either scope. The exact + /// sibling of isShowingTrash(), for the same reason: Empty Spam drops rows + /// from a path-based view that no tag change can express. + bool isShowingSpam() const; + /// The `moved-from:` tag naming `dbRelativeFolder`, or empty when no /// account owns it. /// |
