aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp4
-rw-r--r--src/mainwindow.cpp147
-rw-r--r--src/mainwindow.h18
-rw-r--r--src/messageview.cpp36
-rw-r--r--src/messageview.h9
5 files changed, 194 insertions, 20 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 17f8241..b7cf8f5 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -37,6 +37,10 @@ QStringList KeyMap::knownActions()
// that destroys mail with no undo, and a chord is how it would be run
// by accident. Menu only, which item 132 made a legitimate choice.
QStringLiteral("empty_trash"),
+ // Item 185. Empty trash's sibling, scoped to the selection, and it
+ // carries no default binding for exactly the same reason: the act is
+ // identical and so is the hazard.
+ QStringLiteral("purge"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
QStringLiteral("mark_all_read"),
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 4859b1f..44c7250 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1757,6 +1757,14 @@ void MainWindow::registerActions()
tr("Permanently delete every message in the trash"), [this]() {
emptyTrash();
});
+ // The same act as empty_trash, scoped to the selection: the user's own
+ // words, "they are the same action, scoped differently". So it inherits
+ // both safeguards rather than being reasoned about afresh, the
+ // confirmation and the absent default shortcut.
+ addAction(QStringLiteral("purge"), tr("Delete per&manently..."),
+ tr("Permanently delete the selected messages"), [this]() {
+ purgeSelected();
+ });
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
@@ -2046,6 +2054,11 @@ void MainWindow::buildMenus()
// disabled entry with its shortcut beside it says both that it exists and
// where it applies.
messageMenu->addAction(m_actions.value(QStringLiteral("restore")));
+ // Beside Restore, the other thing a trashed message affords. Hidden
+ // outside the trash rather than greyed, unlike Restore above: this one
+ // destroys, so offering it where it does not apply is worse than teaching
+ // that it exists.
+ messageMenu->addAction(m_actions.value(QStringLiteral("purge")));
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
@@ -2122,6 +2135,11 @@ void MainWindow::buildMenus()
// thing it deliberately does not do.
{ QStringLiteral("cleanup_stranded"), QStringLiteral("system-search") },
{ QStringLiteral("empty_trash"), QStringLiteral("edit-delete-shred") },
+ // Item 185. Distinct from empty_trash's, because both reach the trash
+ // bar and there the icon IS the control: two buttons that destroy
+ // 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") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
@@ -2248,7 +2266,10 @@ void MainWindow::buildMenus()
toolBar->addAction(syncAction);
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("archive")));
- toolBar->addAction(m_actions.value(QStringLiteral("delete")));
+ // Delete is NOT here (item 186). It acts on the displayed message, like
+ // Reply and Forward, so it lives on the pane's own bar by the same rule
+ // items 139 to 141 settled for those two. It stays in the Message menu
+ // and the context menu, so nothing became unreachable.
toolBar->addAction(m_actions.value(QStringLiteral("mark_all_read")));
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("undo")));
@@ -2308,17 +2329,45 @@ void MainWindow::populateMessageBar()
// changed the answer. The guard that IS load-bearing sits one level up in
// updateComposeActions(), where accountForCurrentMessage() would otherwise
// answer about a row this query is discarding.
+ // A THIRD branch since item 185, and the order matters: a draft that has
+ // been deleted is in the trash, where Edit draft is no more use than
+ // Reply. Trash is asked first for that reason.
+ //
+ // Keyed on the SELECTION being in a trash folder, the same predicate
+ // refreshTrashActions() uses, rather than on the trash VIEW: the two
+ // disagree on mail reached from a search, where a message can sit in the
+ // trash while the query was never the trash filter. Reading the view
+ // there would offer Reply on a trashed message, which is the whole
+ // complaint.
QList<QAction *> messageActions;
- if (currentMessageIsADraft()) {
+ QList<QAction *> tinted;
+ if (everySelectedRowIsInATrashFolder()
+ && !m_threadView->selectionModel()->selectedRows().isEmpty()) {
+ // Restore first, then the two purges, which are one act at two
+ // scopes: this message, and the whole trash. Delete is absent because
+ // refreshTrashActions() hides it on mail already in the trash, where
+ // it reported success and did nothing (item 168).
+ messageActions = { m_actions.value(QStringLiteral("restore")),
+ m_actions.value(QStringLiteral("purge")),
+ m_actions.value(QStringLiteral("empty_trash")) };
+ // Restore alone. The two purges are the same act at two scopes and
+ // need no colour to tell them from each other, only from this one.
+ tinted = { m_actions.value(QStringLiteral("restore")) };
+ } else if (currentMessageIsADraft()) {
messageActions = { m_actions.value(QStringLiteral("edit_draft")) };
} else {
+ // Delete joins the pair here (item 186), from the main toolbar. It is
+ // hidden on a reply row and outside its scope by
+ // refreshTrashActions(), which the bar inherits by showing the
+ // window's own QActions rather than copies.
messageActions = { m_actions.value(QStringLiteral("reply")),
- m_actions.value(QStringLiteral("forward")) };
+ m_actions.value(QStringLiteral("forward")),
+ m_actions.value(QStringLiteral("delete")) };
}
m_messageView->setBarActions(
messageActions, { m_actions.value(QStringLiteral("toggle_html")) },
- iconSize);
+ iconSize, tinted);
}
void MainWindow::showShortcutReference()
@@ -3743,6 +3792,15 @@ void MainWindow::refreshTrashActions()
restore->setVisible((!haveSelection || inTrash)
&& !m_replySelectionHidesDelete);
}
+
+ // Item 185. Follows Restore exactly: both are what a message ALREADY in
+ // the trash affords, and neither means anything outside it. Unlike the
+ // pair above, it is not hidden on a reply row: Delete is a
+ // conversation-level act because removing one reply from a live
+ // conversation is not offered, but a reply that is already in the trash
+ // is just mail, and destroying only that one is a coherent thing to ask.
+ if (auto *purge = m_actions.value(QStringLiteral("purge")))
+ purge->setVisible(haveSelection && inTrash);
}
void MainWindow::refreshUnreadAction()
@@ -4263,6 +4321,12 @@ void MainWindow::onThreadDigestLoaded(const ThreadDigest &digest,
m_conversationPathsThreadId = digest.threadId;
m_conversationPaths = digest.messagePaths;
refreshTrashActions();
+ // And the bar, for the same reason and on the same line of reasoning
+ // (item 185). A conversation's trash-ness is not known until the digest
+ // reports every path, so the bar filled at selection time answered from
+ // the summary's single path; refilling here is what makes it agree with
+ // the menu entries refreshTrashActions() just corrected.
+ populateMessageBar();
m_messageView->showDashboard(digest);
}
@@ -6282,7 +6346,17 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
}
if (requestTag == QStringLiteral("empty_trash")) {
- confirmAndPurge(messageIds);
+ const QString accountKey = m_accountBox->currentData().toString();
+ confirmAndPurge(messageIds, accountKey.isEmpty()
+ ? tr("every account")
+ : m_accountBox->currentText());
+ 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.
+ confirmAndPurge(messageIds, QString());
return;
}
@@ -6597,25 +6671,66 @@ void MainWindow::emptyTrash()
Q_ARG(QString, QStringLiteral("empty_trash")));
}
-void MainWindow::confirmAndPurge(const QStringList &messageIds)
+void MainWindow::purgeSelected()
{
- if (messageIds.isEmpty()) {
- showTransientStatus(tr("The trash is already empty"));
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty()) {
+ showTransientStatus(tr("Nothing selected to delete"));
return;
}
- const QString accountKey = m_accountBox->currentData().toString();
- const QString where = accountKey.isEmpty()
- ? tr("every account")
- : m_accountBox->currentText();
+ if (!m_worker) {
+ showTransientStatus(tr("Not connected to the mail index"));
+ return;
+ }
+
+ const ActionScope scope = m_model->scopeForSelection(rows);
+
+ // One combined query rather than a resolve per scope, since two round
+ // trips would reach confirmAndPurge() twice and ask the user twice for
+ // one gesture. `thread:` and `id:` compose in one notmuch query, which is
+ // what lets a mixed selection stay a single confirmation.
+ QStringList terms;
+ terms.reserve(scope.threadIds.size() + scope.messageIds.size());
+ for (const QString &id : scope.threadIds)
+ terms.append(QStringLiteral("thread:%1").arg(id));
+ for (const QString &id : scope.messageIds)
+ terms.append(QStringLiteral("id:%1").arg(id));
+
+ if (terms.isEmpty())
+ return;
+
+ // Resolved by the WORKER, like every other count that precedes a
+ // destructive act: the dialog must name what will actually be destroyed,
+ // and the model holds whatever the view last painted.
+ QMetaObject::invokeMethod(
+ m_worker, "resolveQueryMessages", Qt::QueuedConnection,
+ Q_ARG(QString, terms.join(QStringLiteral(" or "))),
+ Q_ARG(QString, QStringLiteral("purge_selection")));
+}
+
+void MainWindow::confirmAndPurge(const QStringList &messageIds,
+ const QString &where)
+{
+ if (messageIds.isEmpty()) {
+ showTransientStatus(where.isEmpty()
+ ? tr("Nothing selected to delete")
+ : tr("The trash is already empty"));
+ return;
+ }
QMessageBox box(this);
box.setObjectName(QStringLiteral("emptyTrashConfirmation"));
box.setIcon(QMessageBox::Warning);
- box.setWindowTitle(tr("Empty trash"));
- box.setText(tr("Permanently delete %n message(s) from the trash of %1?",
- "", messageIds.size())
- .arg(where));
+ box.setWindowTitle(where.isEmpty() ? tr("Delete permanently")
+ : tr("Empty trash"));
+ box.setText(where.isEmpty()
+ ? tr("Permanently delete %n selected message(s)?", "",
+ messageIds.size())
+ : tr("Permanently delete %n message(s) from the trash "
+ "of %1?", "", messageIds.size())
+ .arg(where));
// Said plainly, because it is the only place in this application where it
// is true.
box.setInformativeText(tr("This cannot be undone."));
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 9c93f4f..3829ae2 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -1272,9 +1272,23 @@ private:
/// which holds whatever the current view happens to show.
void emptyTrash();
+ /// 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
+ /// default shortcut. Only where the ids come from differs, which is why
+ /// there are two entry points rather than a parameter.
+ ///
+ /// Asynchronous for the same reason emptyTrash() is: the count in the
+ /// dialog must be what will be destroyed, so a conversation row's
+ /// messages are resolved against the database rather than counted from
+ /// the model. The answer arrives at onThreadMessagesResolved() tagged
+ /// `purge_selection`.
+ void purgeSelected();
+
/// The confirmation, and the only one in this application. Destroys
- /// nothing if the user declines.
- void confirmAndPurge(const QStringList &messageIds);
+ /// nothing if the user declines. \p where names the scope in the prompt,
+ /// since the two callers destroy very different amounts of mail.
+ void confirmAndPurge(const QStringList &messageIds, const QString &where);
/// Moves each resolved message home, using the tags and paths the WORKER
/// reported rather than anything the model holds.
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 7106ab5..77d79dd 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -673,7 +673,8 @@ void MessageView::applyNoticeBarStyles()
void MessageView::setBarActions(const QList<QAction *> &messageActions,
const QList<QAction *> &viewControls,
- int iconSize)
+ int iconSize,
+ const QList<QAction *> &tinted)
{
m_messageBar->clear();
if (iconSize > 0)
@@ -684,6 +685,39 @@ void MessageView::setBarActions(const QList<QAction *> &messageActions,
m_messageBar->addAction(action);
}
+ // Applied after the actions are added, because a QToolBar creates the
+ // button for an action when it takes it: widgetForAction() returns
+ // nothing before that, so tinting in the loop above would silently do
+ // nothing at all.
+ //
+ // Per-button rather than a bar-wide sheet keyed on the object name, since
+ // the bar refills on every selection change and a sheet naming actions
+ // would have to be rewritten each time anyway. The ground follows the
+ // palette by the same rule applyNoticeBarStyles() uses: QPalette::Base
+ // decides which way round the theme is, so the tint cannot come out
+ // near-white on near-white.
+ if (!tinted.isEmpty()) {
+ const bool dark = palette().color(QPalette::Base).lightnessF() < 0.5;
+ // Green, not the blue the notice bars use: those are informational,
+ // and this marks the one button on a destructive bar that gives mail
+ // back. Each set carries its own ground and border rather than being
+ // the other dimmed, for the reason recorded beside the notice bars.
+ const QString ground = dark ? QStringLiteral("#12301c")
+ : QStringLiteral("#e2f4e6");
+ const QString border = dark ? QStringLiteral("#2b5c39")
+ : QStringLiteral("#a9d5b5");
+ const QString sheet = QStringLiteral(
+ "QToolButton { background: %1; border: 1px solid %2; "
+ "border-radius: 4px; padding: 2px; }").arg(ground, border);
+
+ for (QAction *action : tinted) {
+ if (!action)
+ continue;
+ if (QWidget *button = m_messageBar->widgetForAction(action))
+ button->setStyleSheet(sheet);
+ }
+ }
+
// The stretch is what separates the two scopes, so the view controls end
// up at the right edge. A QToolBar has no addStretch(), so it takes an
// expanding spacer widget.
diff --git a/src/messageview.h b/src/messageview.h
index 10b9430..54dd6e7 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -278,9 +278,16 @@ public:
/// separated from \p messageActions by a stretch: acting on the message
/// and changing how it is displayed are different scopes, which is the
/// confusion the bar exists to remove one level up.
+ /// \p tinted names the actions whose buttons take a coloured ground. The
+ /// trash bar is icons-only like the rest, so the tint is what separates
+ /// the one action that gives mail back from the two that destroy it. Only
+ /// the positive one is tinted, at the user's decision: colouring all
+ /// three would make the bar a warning strip and the tint would stop
+ /// meaning anything.
void setBarActions(const QList<QAction *> &messageActions,
const QList<QAction *> &viewControls,
- int iconSize = 0);
+ int iconSize = 0,
+ const QList<QAction *> &tinted = {});
/// Tells the pane whether the query bar currently holds anything.
///