diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mainwindow.cpp | 114 | ||||
| -rw-r--r-- | src/mainwindow.h | 32 |
2 files changed, 141 insertions, 5 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6c66953..37411e6 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1784,9 +1784,8 @@ void MainWindow::registerActions() purgeSelected(); }); addAction(QStringLiteral("spam"), tr("Mark &spam"), - tr("Add spam and remove inbox"), [this]() { - tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, - tr("Mark spam")); + tr("Move the selected messages to the spam folder"), [this]() { + spamSelected(); }); // Item 57. The LABEL is "Important"; the action name and the tag are both // still `flag`/`flagged`, deliberately. The name is what a user writes in @@ -3972,7 +3971,8 @@ void MainWindow::refreshScopedActionLabels() tr("Move every message of the selected threads out of the " "trash")); relabel(QStringLiteral("spam"), tr("Mark thread as &spam"), - tr("Add spam and remove inbox on the selected threads")); + tr("Move every message of the selected threads to the spam " + "folder")); relabel(QStringLiteral("flag"), tr("&Important thread"), tr("Mark every message of the selected threads as important")); } else { @@ -3983,7 +3983,7 @@ void MainWindow::refreshScopedActionLabels() relabel(QStringLiteral("restore"), tr("&Restore from trash"), tr("Move the selected messages out of the trash")); relabel(QStringLiteral("spam"), tr("Mark &spam"), - tr("Add spam and remove inbox")); + tr("Move the selected messages to the spam folder")); relabel(QStringLiteral("flag"), tr("&Important"), tr("Add or remove the important tag")); } @@ -6413,6 +6413,105 @@ void MainWindow::trashThreads(const QStringList &threadIds) Q_ARG(QString, QStringLiteral("delete_thread"))); } +void MainWindow::spamSelected() +{ + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + if (rows.isEmpty()) + return; + + // Delete's sibling, resolved the same per-row way (item 177): a + // conversation row spams its conversation, a thread of one spams its + // message. Both halves are run, because a selection really can hold one of + // each; they travel different routes for the reason trashSelected() + // records. + const ActionScope scope = m_model->scopeForSelection(rows); + + if (!scope.threadIds.isEmpty()) + spamThreads(scope.threadIds); + + if (scope.messageIds.isEmpty()) + return; + + QHash<QString, QString> pathById; + for (const QString &messageId : scope.messageIds) + pathById.insert(messageId, m_model->messageById(messageId).filePath); + + spamMessages(scope.messageIds, pathById, scope.messageIds.size()); +} + +void MainWindow::spamMessages(const QStringList &messageIds, + const QHash<QString, QString> &pathById, + int messageCount, + const QStringList &wholeThreadIds) +{ + if (messageIds.isEmpty()) + return; + + // Grouped by destination, exactly as trashMessages() is: moveMessages() + // takes one folder per call, and a selection can span accounts with + // different spam folders. + QHash<QString, QStringList> bySpam; + QStringList unconfigured; + for (const QString &messageId : messageIds) { + const Account account = + accountForMessagePath(pathById.value(messageId)); + if (account.spam.isEmpty()) { + unconfigured.append(messageId); + continue; + } + bySpam[account.maildir + QLatin1Char('/') + account.spam] + .append(messageId); + } + + // The second line of defence, as for trash: the config loader warns, but a + // user who never fixed it still needs the gesture to say it did nothing + // rather than move the file somewhere invented. + if (!unconfigured.isEmpty()) { + m_statusLabel->setText( + tr("%n message(s) could not be marked as spam: no spam folder is " + "configured for their account.", "", int(unconfigured.size()))); + } + + if (bySpam.isEmpty()) + return; + + for (auto it = bySpam.cbegin(); it != bySpam.cend(); ++it) { + // `unread` and `inbox` go with the message, exactly as Delete strips + // them: marking spam is a decision about the message, and without the + // `inbox` removal a message spammed FROM the inbox keeps the tag the + // Inbox filter matches on and stays in that view. The origin is the + // placeholder, resolved per message once the move is confirmed. + sendMove(it.value(), it.key(), + { QStringLiteral("spam"), kOriginTagPlaceholder() }, + { QStringLiteral("unread"), QStringLiteral("inbox") }, + tr("Mark spam"), false, wholeThreadIds); + } + + announceAction( + tr("%1: %n message(s)", "", messageCount).arg(tr("Mark spam"))); +} + +void MainWindow::spamThreads(const QStringList &threadIds) +{ + if (threadIds.isEmpty()) + return; + + // Asked of the WORKER rather than resolved here, and repainted HERE + // synchronously before it: the same shape as trashThreads(), for the same + // two reasons. An unexpanded thread's reply ids and paths exist only in + // the database; and the card must not wait for that round trip. + for (const QString &threadId : threadIds) + m_model->applyTagChange(threadId, { QStringLiteral("spam") }, + { QStringLiteral("inbox") }); + + m_pendingThreadScope = threadIds; + QMetaObject::invokeMethod(m_worker, "resolveThreadMessages", + Qt::QueuedConnection, + Q_ARG(QStringList, threadIds), + Q_ARG(QString, QStringLiteral("spam_thread"))); +} + void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, const QStringList &paths, const QStringList &tags, @@ -6467,6 +6566,11 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, return; } + if (requestTag == QStringLiteral("spam_thread")) { + spamMessages(messageIds, pathById, messageIds.size(), threadScope); + return; + } + if (requestTag == QStringLiteral("restore_messages")) { restoreResolvedMessages(messageIds, paths, tags); return; diff --git a/src/mainwindow.h b/src/mainwindow.h index 97d90e5..14bb06f 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -226,6 +226,15 @@ public: refreshTrashActions(); } + /// The trash predicate, exposed because `spam` is a DIFFERENT folder and + /// the predicate must not answer for it. A test seam rather than a + /// behavioural one: asserting on Delete's visibility would prove the same + /// thing only through the label refresh. + bool everySelectedRowIsInATrashFolderForTesting() const + { + return everySelectedRowIsInATrashFolder(); + } + /// Runs a purge without the confirmation, which a test cannot drive: a /// modal blocks the thread it is shown on (item 84). What this exists to /// cover is what happens AFTER the user confirms. @@ -1233,6 +1242,29 @@ private: /// back where it came from. void untrashThreads(const QStringList &threadIds); + /// Moves each selected row's message to its account's spam folder, tagging + /// it `spam` and recording where it came from. Delete's sibling. + void spamSelected(); + + /// The half of spamSelected() that does the work, given the messages and + /// their paths. + /// + /// Shaped exactly like trashMessages(): paths are passed in rather than + /// looked up, because the thread-scoped caller has messages the MODEL has + /// never seen. + void spamMessages(const QStringList &messageIds, + const QHash<QString, QString> &pathById, + int messageCount, + const QStringList &wholeThreadIds = {}); + + /// Moves every message of the named THREADS to their accounts' spam + /// folder. + /// + /// Asynchronous like trashThreads(), and for the same reason: the ids and + /// paths of an unexpanded thread's messages live only in the database, so + /// this asks the worker and finishes in onThreadMessagesResolved(). + void spamThreads(const QStringList &threadIds); + /// Runs the thread-scoped delete once the worker has resolved the /// threads to messages. void onThreadMessagesResolved(const QStringList &messageIds, |
