summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp114
-rw-r--r--src/mainwindow.h32
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,