aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp48
-rw-r--r--src/mainwindow.cpp532
-rw-r--r--src/mainwindow.h117
-rw-r--r--src/notmuchworker.cpp7
-rw-r--r--src/threadlistmodel.cpp67
-rw-r--r--src/threadlistmodel.h37
6 files changed, 393 insertions, 415 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 6605882..17f8241 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -43,26 +43,6 @@ QStringList KeyMap::knownActions()
QStringLiteral("edit_tags"),
QStringLiteral("tag_rules"),
QStringLiteral("flag"),
- // The whole-thread counterparts (item 108). The names above act on the
- // message a row displays; these act on its entire thread. Separate
- // names rather than a scope flag, because a name is what a user writes
- // in [keys]: giving `delete` new semantics would silently change an
- // existing config, and renaming it would break one that mentions it.
- //
- // Unbound by default. They are reached through the "Whole thread"
- // submenu, and inventing five more default chords for actions most
- // users will rarely want is worse than leaving them to bind what they
- // use.
- QStringLiteral("archive_thread"),
- QStringLiteral("delete_thread"),
- QStringLiteral("spam_thread"),
- // Item 112 split the thread toggle in two. Neither carries a default
- // chord, at the user's choice: since item 132 a shortcut is a chosen
- // subset rather than a requirement, and Ctrl+Alt+U meant whichever
- // direction the union happened to pick, which is what made it wrong.
- QStringLiteral("mark_thread_read"),
- QStringLiteral("mark_thread_unread"),
- QStringLiteral("flag_thread"),
// Compose and send (item 123). save_message deliberately carries no
// default chord: since item 132 a shortcut is a chosen subset rather
// than a requirement, and writing the raw message to a file is the
@@ -121,17 +101,14 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
// Compose and send (item 123), listed where the Message menu presents
// them: composing sits above organising.
//
- // PROVISIONAL. The user intends to rework the bindings, and
- // Ctrl+Alt+R for reply_no_quote is an imperfect fit: the Ctrl+Alt tier
- // elsewhere means a WIDER SCOPE (the five whole-thread actions), not a
- // variant of the same scope.
+ // PROVISIONAL. The user intends to rework the bindings.
//
// Each was checked against every sequence in this table, not merely
// against the lines above it: these sit near the top, so most of the
// table is BELOW them, Ctrl+Shift+U and Ctrl+Shift+S among it.
- // Checking only upwards would miss exactly those. The near misses:
- // Ctrl+R is restore, Ctrl+A is select_all and Ctrl+Alt+S is
- // spam_thread, so none of these five is a reuse.
+ // Checking only upwards would miss exactly those. The near misses are
+ // Ctrl+R for restore and Ctrl+A for select_all, so none of these five
+ // is a reuse.
//
// save_message gets none. Item 132 made a chord a chosen subset rather
// than a requirement, and this is the escape hatch nobody presses a
@@ -164,8 +141,8 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Ctrl+R"), QStringLiteral("restore") },
// Item 103's cleanup. A chord rather than a plain key: it replaces the
// whole view, and it is reached from a menu far more often than from
- // the keyboard. Ctrl+Shift+D is message_details and Ctrl+Alt+D is
- // delete_thread, so this takes the T of "trash".
+ // the keyboard. Ctrl+Shift+D is message_details, so this takes the T
+ // of "trash".
{ QStringLiteral("Ctrl+Alt+T"), QStringLiteral("cleanup_stranded") },
{ QStringLiteral("Ctrl+Shift+S"), QStringLiteral("spam") },
{ QStringLiteral("Ctrl+U"), QStringLiteral("toggle_unread") },
@@ -174,19 +151,6 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
// action takes the harder chord rather than the easier one.
{ QStringLiteral("Ctrl+Shift+U"), QStringLiteral("mark_all_read") },
{ QStringLiteral("Ctrl+I"), QStringLiteral("flag") },
- // The whole-thread tier (item 108), one modifier out from each
- // message-scoped twin: Ctrl+D deletes the message a row displays,
- // Ctrl+Alt+D deletes its conversation.
- //
- // Ctrl+ALT, not Ctrl+Shift. The obvious pairing is taken twice over:
- // Ctrl+Shift+S is `spam` and Ctrl+Shift+U is `mark_all_read`, both
- // shipped and both in users' fingers. Reusing either would silently
- // change what an existing key does, which is the same objection that
- // made these separate action names rather than a flag on the old ones.
- { QStringLiteral("Ctrl+Alt+E"), QStringLiteral("archive_thread") },
- { QStringLiteral("Ctrl+Alt+D"), QStringLiteral("delete_thread") },
- { QStringLiteral("Ctrl+Alt+S"), QStringLiteral("spam_thread") },
- { QStringLiteral("Ctrl+Alt+I"), QStringLiteral("flag_thread") },
{ QStringLiteral("Ctrl+T"), QStringLiteral("edit_tags") },
// Shifted against Ctrl+T for the same reason Ctrl+Shift+U is shifted
// against Ctrl+U: this is the standing version of tagging, applied to
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index b64e20b..7d9cfbc 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1011,14 +1011,24 @@ void MainWindow::composeNew()
void MainWindow::composeReply(ComposeContext::Kind kind, bool quote)
{
- // messageScopeFor() semantics, NOT threadFor(): a thread row means the one
- // message its card shows, a reply row means itself. Replying to a thread
- // is meaningless; a reply answers a message.
- //
+ const QModelIndex current = m_threadView->currentIndex();
+
+ // A CONVERSATION row answers the thread, not a message (item 177). The
+ // old comment here said "replying to a thread is meaningless; a reply
+ // answers a message", and half of that survives: this still resolves to
+ // one message. What changed is WHICH one. A conversation row's card
+ // shows the thread's FIRST message, and answering that would thread the
+ // reply off the opening post of a discussion that has moved on, so the
+ // newest message is asked for instead. Only `reply` reaches here on such
+ // a row; refreshScopedActionLabels() hides the other four.
+ if (m_model->isConversationRow(current)) {
+ replyToThread(m_model->threadFor(current).threadId);
+ return;
+ }
+
// It takes a QModelIndexList, not a single index, so the current index is
// wrapped rather than passed bare.
- const ActionScope scope =
- m_model->messageScopeFor({ m_threadView->currentIndex() });
+ const ActionScope scope = m_model->scopeForSelection({ current });
if (scope.messageIds.isEmpty()) {
showTransientStatus(tr("No message is selected"));
return;
@@ -1031,6 +1041,27 @@ void MainWindow::composeReply(ComposeContext::Kind kind, bool quote)
requestMessageForCompose(scope.messageIds.first(), kind, quote);
}
+void MainWindow::replyToThread(const QString &threadId)
+{
+ if (threadId.isEmpty()) {
+ showTransientStatus(tr("No thread is selected"));
+ return;
+ }
+
+ // A round trip, because the model cannot answer this. The summary carries
+ // only the thread's FIRST message, and an unexpanded conversation holds no
+ // nodes for its replies at all, so the newest message's id exists only in
+ // the database. resolveQuery() sorts newest-first explicitly for this.
+ //
+ // m_pendingThreadScope is deliberately NOT set. It carries the ids a MOVE
+ // is about to apply to, and a reply moves nothing; setting it would leave
+ // a delete arriving next reading a scope this gesture left behind.
+ QMetaObject::invokeMethod(
+ m_worker, "resolveThreadMessages", Qt::QueuedConnection,
+ Q_ARG(QStringList, QStringList{ threadId }),
+ Q_ARG(QString, QStringLiteral("reply_thread")));
+}
+
void MainWindow::editDraft()
{
editDraftAt(m_threadView->currentIndex());
@@ -1038,9 +1069,9 @@ void MainWindow::editDraft()
void MainWindow::editDraftAt(const QModelIndex &index)
{
- // messageScopeFor(), like composeReply(): a thread row means the one
- // message its card shows.
- const ActionScope scope = m_model->messageScopeFor({ index });
+ // A draft is one message by construction, so a drafts row is never a
+ // conversation and this needs no thread branch of its own.
+ const ActionScope scope = m_model->scopeForSelection({ index });
if (scope.messageIds.isEmpty()) {
showTransientStatus(tr("No message is selected"));
return;
@@ -1445,7 +1476,16 @@ void MainWindow::updateComposeActions()
void MainWindow::saveDisplayedMessage(const QString &chosenDirectory)
{
const QModelIndex current = m_threadView->currentIndex();
- const ActionScope scope = m_model->messageScopeFor({ current });
+
+ // Never reached on a conversation row: refreshScopedActionLabels() hides
+ // Save there, because a conversation names no single file. Guarded anyway,
+ // since a hidden QAction still fires from a shortcut.
+ if (m_model->isConversationRow(current)) {
+ showTransientStatus(tr("Select one message to save"));
+ return;
+ }
+
+ const ActionScope scope = m_model->scopeForSelection({ current });
if (scope.messageIds.isEmpty()) {
showTransientStatus(tr("No message is selected"));
return;
@@ -1746,77 +1786,6 @@ void MainWindow::registerActions()
editTagsOnSelection();
});
- // The whole-thread counterparts (item 108). Separate action NAMES, because
- // a name is what a user writes in [keys]: reusing `delete` with new
- // semantics would silently change what an existing config does, and
- // renaming it would break one that mentions it. These are unbound by
- // default; the submenu is how they are reached.
- //
- // Each one is its message-scoped twin with TagScope::Thread, so the two
- // cannot drift in what they write, only in what they write it to.
- addAction(QStringLiteral("archive_thread"), tr("&Archive thread"),
- tr("Remove inbox from every message of the selected threads"),
- [this]() {
- tagSelected({}, { QStringLiteral("inbox") }, tr("Archive thread"),
- TagScope::Thread);
- });
- addAction(QStringLiteral("delete_thread"), tr("&Delete thread"),
- tr("Add or remove the deleted tag on whole threads"), [this]() {
- // A MOVE now, like its message-scoped twin. It tagged and moved
- // nothing until item 103's follow-up, so "Delete thread" left a whole
- // conversation sitting in the inbox wearing a `deleted` chip: exactly
- // the half-deleted state Delete stopped producing.
- //
- // The direction is read per MESSAGE, not from the thread's tag union.
- // A thread whose root was deleted on its own carries `deleted` in the
- // union while its replies do not, and asking the union there ran
- // Delete a second time on messages already in the trash.
- if (everySelectedRowHasTag(QStringLiteral("deleted"), TagScope::Thread)) {
- restoreSelectedThreads();
- } else {
- trashSelectedThreads();
- }
- });
- addAction(QStringLiteral("spam_thread"), tr("Mark thread as &spam"),
- tr("Add spam and remove inbox on whole threads"), [this]() {
- tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
- tr("Mark thread spam"), TagScope::Thread);
- });
- // Two fixed directions rather than one toggle, and the asymmetry with the
- // message-scoped twin is the point (item 112). `ThreadSummary::tags` is
- // notmuch's UNION over the conversation, so a thread holding even one
- // unread message answers "unread" and a toggle reading that predicate
- // always chose "mark read": there was no input that reached "mark thread
- // unread" on a mixed thread, which is exactly the thread a user wants it
- // for. A union is not a state, and a toggle needs a state.
- //
- // The message-scoped `toggle_unread` stays a toggle, because one message
- // has a real two-valued state. Do not unify them.
- addAction(QStringLiteral("mark_thread_read"), tr("Mark thread &read"),
- tr("Remove the unread tag from every message of the selected "
- "threads"), [this]() {
- m_markReadTimer->stop();
- m_markReadMessageId.clear();
- tagSelected({}, { QStringLiteral("unread") },
- tr("Mark thread read"), TagScope::Thread);
- });
- addAction(QStringLiteral("mark_thread_unread"), tr("Mark thread &unread"),
- tr("Add the unread tag to every message of the selected threads"),
- [this]() {
- // Cancels the automatic mark-read for the same reason its
- // message-scoped twin does: a thread marked unread by hand must not be
- // undone a moment later by a timer armed when it was opened.
- m_markReadTimer->stop();
- m_markReadMessageId.clear();
- tagSelected({ QStringLiteral("unread") }, {},
- tr("Mark thread unread"), TagScope::Thread);
- });
- addAction(QStringLiteral("flag_thread"), tr("&Important"),
- tr("Mark every message of the selected threads as important"),
- [this]() {
- tagSelected({ QStringLiteral("flagged") }, {},
- tr("Mark thread important"), TagScope::Thread);
- });
addAction(QStringLiteral("tag_rules"), tr("Tagging &rules..."),
tr("Edit the rules that tag mail as it arrives"), [this]() {
showTagRulesDialog();
@@ -2037,8 +2006,6 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("mark_all_read")));
messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
- messageMenu->addSeparator();
- messageMenu->addMenu(buildThreadActionsMenu(messageMenu));
// Separated from the entries above: those act on the selection, this edits
// a rule store shared with mailctl and changes nothing that is on screen.
messageMenu->addSeparator();
@@ -2142,25 +2109,12 @@ void MainWindow::buildMenus()
{ QStringLiteral("zoom_out"), QStringLiteral("zoom-out") },
{ QStringLiteral("zoom_reset"), QStringLiteral("zoom-original") },
- // The whole-thread tier (item 108) deliberately SHARES each icon with
- // its message-scoped twin. The no-duplicates rule exists because the
- // toolbar can be icon-only, where the icon is the entire control;
- // these five never reach the toolbar. They live in a submenu whose
- // entries always carry text, and "Delete thread" beside the delete
- // icon is the honest pairing: the same operation, a wider scope, with
- // the words saying which. Inventing five different shapes for the same
- // five operations would be less clear, not more.
- { QStringLiteral("archive_thread"), QStringLiteral("mail-archive") },
- { QStringLiteral("delete_thread"), QStringLiteral("edit-delete") },
- { QStringLiteral("spam_thread"), QStringLiteral("mail-mark-junk") },
- { QStringLiteral("mark_thread_read"), QStringLiteral("mail-mark-read") },
- { QStringLiteral("mark_thread_unread"), QStringLiteral("mail-mark-unread") },
- { QStringLiteral("flag_thread"), QStringLiteral("mail-mark-important") },
-
- // Compose and send (item 123). reply_no_quote SHARES reply's icon for
- // the same reason the five above share theirs: it never reaches the
- // toolbar, it is a menu entry that always carries its text, and
- // "Reply without quoting" beside the reply icon is the honest pairing.
+ // Compose and send (item 123). reply_no_quote SHARES reply's icon,
+ // which the no-duplicates rule allows because that rule exists for the
+ // icon-only TOOLBAR, where the icon is the entire control: it never
+ // reaches the toolbar, it is a menu entry that always carries its
+ // text, and "Reply without quoting" beside the reply icon is the
+ // honest pairing.
// It is named in the exception list in noTwoActionsShareAnIcon(), so
// putting it on the toolbar fails that test rather than passing
// silently.
@@ -2200,8 +2154,6 @@ void MainWindow::buildMenus()
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
m_threadContextMenu->addSeparator();
- m_threadContextMenu->addMenu(buildThreadActionsMenu(m_threadContextMenu));
- m_threadContextMenu->addSeparator();
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
@@ -3698,35 +3650,66 @@ void MainWindow::refreshTrashActions()
// moveMessages() finds the file already in the destination and takes its
// early-return branch, which counts an unsynced change for a move that
// never happened (item 168).
- if (auto *del = m_actions.value(QStringLiteral("delete")))
- del->setVisible(!haveSelection || !inTrash);
+ // ...and hidden on a reply row as well (item 177): deleting is a
+ // conversation-level act, so a reply offers no Delete at all. The two
+ // hides are ORed rather than fought over, which is why this reads a flag
+ // refreshScopedActionLabels() sets instead of walking the selection twice.
+ if (auto *del = m_actions.value(QStringLiteral("delete"))) {
+ del->setVisible((!haveSelection || !inTrash)
+ && !m_replySelectionHidesDelete);
+ }
// The mirror, which shipped beside it: Restore was added unconditionally
// to both menus and so was offered on mail that was never deleted.
- if (auto *restore = m_actions.value(QStringLiteral("restore")))
- restore->setVisible(!haveSelection || inTrash);
+ if (auto *restore = m_actions.value(QStringLiteral("restore"))) {
+ restore->setVisible((!haveSelection || inTrash)
+ && !m_replySelectionHidesDelete);
+ }
}
void MainWindow::refreshUnreadAction()
{
// The user's design (item 112 and its duplicates 99/147): the label says
// which way the action will go, and on a selection with no single state
- // the entry is HIDDEN rather than labelled wrongly. The thread submenu is
- // then the route, whose entries are absolute and work whatever the mix.
+ // the entry is HIDDEN rather than labelled wrongly.
+ //
+ // The "Whole thread" submenu used to be the route out of the hidden case,
+ // with two absolute entries that worked whatever the mix. It is gone
+ // (item 177), and nothing replaces it: on a conversation row the label
+ // now names the THREAD, and the thread's union is a single state for the
+ // same reason it was not a message's. A genuinely mixed MULTI-row
+ // selection still hides the entry, and Edit tags beside it is the route.
auto *action = m_actions.value(QStringLiteral("toggle_unread"));
if (!action)
return;
+ // Mixed as well as Conversations: the write really will take whole threads
+ // for the rows that are conversations, so the wider claim is the honest
+ // one. Same rule as refreshScopedActionLabels(), which must not disagree.
+ const SelectionKind kind = selectionKind();
+ const bool namesTheThread = kind == SelectionKind::Conversations
+ || kind == SelectionKind::Mixed;
+
switch (selectionTagPresence(QStringLiteral("unread"))) {
case TagPresence::Every:
action->setVisible(true);
- action->setText(tr("Mark as &read"));
- action->setStatusTip(tr("Remove the unread tag from the selection"));
+ action->setText(namesTheThread ? tr("Mark thread as &read")
+ : tr("Mark as &read"));
+ action->setStatusTip(
+ namesTheThread
+ ? tr("Remove the unread tag from every message of the "
+ "selected threads")
+ : tr("Remove the unread tag from the selection"));
break;
case TagPresence::None:
action->setVisible(true);
- action->setText(tr("Mark as &unread"));
- action->setStatusTip(tr("Add the unread tag to the selection"));
+ action->setText(namesTheThread ? tr("Mark thread as &unread")
+ : tr("Mark as &unread"));
+ action->setStatusTip(
+ namesTheThread
+ ? tr("Add the unread tag to every message of the selected "
+ "threads")
+ : tr("Add the unread tag to the selection"));
break;
case TagPresence::Mixed:
// No honest label exists, so there is no label to show. Hidden rather
@@ -3736,6 +3719,118 @@ void MainWindow::refreshUnreadAction()
}
}
+MainWindow::SelectionKind MainWindow::selectionKind() const
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty())
+ return SelectionKind::Empty;
+
+ int conversations = 0;
+ for (const QModelIndex &index : rows) {
+ if (m_model->isConversationRow(index))
+ ++conversations;
+ }
+ if (conversations == 0)
+ return SelectionKind::Messages;
+ return conversations == rows.size() ? SelectionKind::Conversations
+ : SelectionKind::Mixed;
+}
+
+void MainWindow::refreshScopedActionLabels()
+{
+ // One pass, one selection, both jobs. Splitting the label from the
+ // visibility would let the two answer from different reads of the
+ // selection, and a hidden action wearing the wrong label is worse than
+ // either fault alone.
+ const SelectionKind kind = selectionKind();
+ const bool conversation = kind == SelectionKind::Conversations;
+
+ // Mixed counts as a conversation for the LABEL, because the write really
+ // will take whole threads for the rows that are conversations, and the
+ // wider claim is the honest one when the selection holds both.
+ const bool namesTheThread = conversation || kind == SelectionKind::Mixed;
+
+ const auto relabel = [this](const QString &name, const QString &text,
+ const QString &tip) {
+ if (QAction *action = m_actions.value(name)) {
+ action->setText(text);
+ action->setStatusTip(tip);
+ }
+ };
+
+ if (namesTheThread) {
+ relabel(QStringLiteral("archive"), tr("&Archive thread"),
+ tr("Remove inbox from every message of the selected threads"));
+ relabel(QStringLiteral("delete"), tr("&Delete thread"),
+ tr("Move every message of the selected threads to the trash"));
+ relabel(QStringLiteral("restore"), tr("&Restore thread from trash"),
+ 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"));
+ relabel(QStringLiteral("flag"), tr("&Important thread"),
+ tr("Mark every message of the selected threads as important"));
+ } else {
+ relabel(QStringLiteral("archive"), tr("&Archive"),
+ tr("Remove the inbox tag"));
+ relabel(QStringLiteral("delete"), tr("&Delete"),
+ tr("Add or remove the deleted tag"));
+ 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"));
+ relabel(QStringLiteral("flag"), tr("&Important"),
+ tr("Add or remove the important tag"));
+ }
+
+ // Delete and Archive are ABSENT on a reply, not disabled, at the user's
+ // decision: "I don't think I'd want to be able to remove a single reply
+ // from a thread". A thread of ONE is still a message row and keeps them,
+ // because there deleting the message and deleting the conversation are
+ // the same act. So the test is not "is this a message row" but "is this a
+ // reply", which only a message row can be.
+ bool anyReply = false;
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ for (const QModelIndex &index : rows) {
+ if (m_model->isMessageRow(index)) {
+ anyReply = true;
+ break;
+ }
+ }
+
+ // Delete's and Restore's visibility is also refreshTrashActions()' job,
+ // which runs after this and would overwrite a hide made here. It is told
+ // about the reply case rather than asked to repeat the walk.
+ m_replySelectionHidesDelete = anyReply;
+
+ if (QAction *archive = m_actions.value(QStringLiteral("archive")))
+ archive->setVisible(!anyReply);
+
+ // The mirror, on a conversation row: it shows no message, so the actions
+ // that need one cannot mean what they usually do. Reply survives as the
+ // thread's own, relabelled above; the other four go.
+ for (const QString &name : { QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward"),
+ QStringLiteral("save_message") }) {
+ if (QAction *action = m_actions.value(name))
+ action->setVisible(!conversation);
+ }
+
+ if (QAction *reply = m_actions.value(QStringLiteral("reply"))) {
+ if (conversation) {
+ reply->setText(tr("Reply to this &thread"));
+ reply->setStatusTip(
+ tr("Add an answer to the end of this conversation"));
+ } else {
+ reply->setText(tr("Re&ply"));
+ reply->setStatusTip(tr("Reply to the displayed message"));
+ }
+ }
+}
+
void MainWindow::onSelectionChanged()
{
// Here rather than in the currentRowChanged handler: that signal is
@@ -3743,6 +3838,7 @@ void MainWindow::onSelectionChanged()
// selectedRows() there sees the PREVIOUS selection and would label the
// action for the rows the user just left (CLAUDE.md, verified Qt 6.11).
refreshUnreadAction();
+ refreshScopedActionLabels();
refreshTrashActions();
const QModelIndexList rows = m_threadView->selectionModel()->selectedRows();
@@ -3753,7 +3849,7 @@ void MainWindow::onSelectionChanged()
// a message row for one, and the keypress looks identical. Naming it
// here is what this project does instead of a confirmation dialog,
// which CLAUDE.md rules out for tag mutations.
- const ActionScope scope = m_model->scopeFor(rows);
+ const ActionScope scope = m_model->scopeForSelection(rows);
if (scope.wholeThread) {
m_selectionMessage =
@@ -3814,7 +3910,7 @@ void MainWindow::onSelectionChanged()
// Reported per row kind rather than as a bare row count, so a mixed
// selection says what it will really touch instead of calling three replies
// "3 threads".
- const ActionScope scope = m_model->scopeFor(rows);
+ const ActionScope scope = m_model->scopeForSelection(rows);
if (!scope.threadIds.isEmpty() && scope.messageIds.isEmpty()) {
m_selectionMessage =
tr("%n thread(s) selected (%1 messages)", "", scope.threadIds.size())
@@ -5086,7 +5182,7 @@ QVector<PendingChange> MainWindow::pendingChangeSnapshot() const
rows.append(PendingChange{ id, false, it->action, QString(), -1 });
}
- // Held THREAD edits, which stay thread-scoped: a `*_thread` action is what
+ // Held THREAD edits, which stay thread-scoped: a CONVERSATION row is what
// made them, and reporting the messages instead would claim the user acted
// on each one. One row per thread the edit named, since a single edit can
// cover a multi-row selection.
@@ -5204,7 +5300,8 @@ void MainWindow::scheduleMarkRead(const QString &messageId, bool unread)
return;
// A row the model cannot name a message for. Marking its thread instead
- // would be the escalation item 108 removed.
+ // would be a silent escalation: the automatic mark-read is about the
+ // message on display, never about the conversation around it.
if (messageId.isEmpty())
return;
@@ -5278,29 +5375,32 @@ QString MainWindow::currentThreadFirstMessageId() const
return {};
}
-bool MainWindow::everySelectedRowHasTag(const QString &tag,
- TagScope scope) const
+bool MainWindow::everySelectedRowHasTag(const QString &tag) const
{
// Kept as the direction question, which only has two answers to give: a
// mixed selection has to go one way, and this says which. The LABEL asks
// selectionTagPresence() instead, because a label can say "these disagree"
// and a direction cannot.
- return selectionTagPresence(tag, scope) == TagPresence::Every;
+ return selectionTagPresence(tag) == TagPresence::Every;
}
-MainWindow::TagPresence MainWindow::selectionTagPresence(const QString &tag,
- TagScope scope) const
+MainWindow::TagPresence MainWindow::selectionTagPresence(
+ const QString &tag) const
{
// What a toggle asks before choosing its direction, for both Delete and
// Toggle unread.
//
- // Per ROW, and each row is asked about what it stands for: a reply row
- // reports the message's tags, a thread row the thread's. Asking a reply's
- // THREAD is the trap both toggles fell into. The write is message-scoped,
- // so it never changes the thread's tags; the thread's answer therefore
- // never moves however many times the key is pressed, and the toggle
- // becomes one-way. On the second press it re-sends a tag the message
- // already has, which is a no-op, and a no-op repaints nothing.
+ // Per ROW, and each row is asked about what it stands for. The same
+ // question ThreadListModel::scopeForSelection() answers for the WRITE, and
+ // it has to be the same question: a direction taken from one object while
+ // the write lands on another is how a toggle goes one-way.
+ //
+ // A reply row reports the message's tags. Asking a reply's THREAD is the
+ // trap both toggles fell into: the write is message-scoped, so it never
+ // changes the thread's tags; the thread's answer therefore never moves
+ // however many times the key is pressed. On the second press it re-sends a
+ // tag the message already has, which is a no-op, and a no-op repaints
+ // nothing.
//
// One direction for the WHOLE selection, which is the rule Delete
// established: toggling each row independently would leave one keystroke
@@ -5313,37 +5413,30 @@ MainWindow::TagPresence MainWindow::selectionTagPresence(const QString &tag,
int withTag = 0;
for (const QModelIndex &index : rows) {
QStringList tags;
- if (scope == TagScope::Thread) {
+ if (m_model->isConversationRow(index)) {
+ // The conversation's own union, which is what a conversation-scoped
+ // write is about to change. Reading one message here would make the
+ // toggle disagree with itself the moment the thread is mixed.
tags = m_model->threadFor(index).tags;
} else if (m_model->isMessageRow(index)) {
tags = m_model->messageAt(index).tags;
} else {
- // A thread row answers about the MESSAGE ITS CARD DISPLAYS, which
- // is what it acts on. threadFor() already substitutes that
- // message's own tags for the thread's union when they are known
- // (item 110), so this reads the row's real state rather than a
- // union over messages it does not stand for.
- //
- // This used to read the union deliberately, with a comment
- // calling the imprecision bounded because no per-message tags
- // existed in the model. They do now: ThreadSummary carries
- // firstMessageTags from the query, so an UNEXPANDED row already
- // knows its own tags, and the comment outlived the fact.
- //
- // The cost of the union was not bounded once Delete became a
- // MOVE. Deleting the root of a three-message thread left the two
- // replies undeleted, so the union carried no `deleted`, so a
- // second press read the row as not-deleted and deleted it AGAIN:
- // the message was moved trash-to-trash and came out carrying
- // `deleted`, `deleted-from:inbox` and `deleted-from:Trash` at
- // once, with no way back. A tag toggle merely re-applied a tag it
- // already had; a move re-applies the MOVE.
+ // A thread row that is NOT a conversation: a thread of one, whose
+ // union IS its message. Asked about that message anyway rather
+ // than about the summary, because the two can diverge in one
+ // direction that matters, described below.
//
- // Resolved through messageById() on the row's own message, which
- // is the id messageScopeFor() will act on. Asking the same
- // question the write asks is what keeps the direction and the
- // write from disagreeing; the union answered a question about a
- // conversation when the row stands for one message.
+ // The union used to be read for EVERY thread row, with a comment
+ // calling the imprecision bounded because a tag toggle at worst
+ // re-applied a tag the message already had. That stopped being
+ // bounded when Delete became a MOVE: deleting the root of a
+ // three-message thread left the replies undeleted, so the union
+ // carried no `deleted`, so a second press read the row as
+ // not-deleted and deleted it AGAIN, trash-to-trash, ending with
+ // `deleted-from:inbox` and `deleted-from:Trash` at once and no way
+ // back. Item 177 removes the case rather than the symptom: a
+ // three-message row is a conversation now and is asked about its
+ // conversation, above.
const ThreadSummary summary = m_model->threadFor(index);
const MessageNode own =
m_model->messageById(summary.firstMessageId);
@@ -5370,25 +5463,6 @@ ThreadSummary MainWindow::threadForCurrentRowForTesting() const
return m_model->threadFor(m_threadView->currentIndex());
}
-QMenu *MainWindow::buildThreadActionsMenu(QWidget *parent)
-{
- // Built per call rather than shared. A QMenu belongs to one place in one
- // menu tree, and adding the same instance to both the menu bar and the
- // context menu gives whichever added it last the object. The ACTIONS are
- // shared, which is what has to stay consistent; the menu holding them is
- // just a container.
- auto *menu = new QMenu(tr("&Whole thread"), parent);
- menu->setObjectName(QStringLiteral("threadActionsMenu"));
- menu->addAction(m_actions.value(QStringLiteral("archive_thread")));
- menu->addAction(m_actions.value(QStringLiteral("delete_thread")));
- menu->addAction(m_actions.value(QStringLiteral("spam_thread")));
- menu->addSeparator();
- menu->addAction(m_actions.value(QStringLiteral("mark_thread_read")));
- menu->addAction(m_actions.value(QStringLiteral("mark_thread_unread")));
- menu->addAction(m_actions.value(QStringLiteral("flag_thread")));
- return menu;
-}
-
QHash<QString, int> MainWindow::selectionTagCounts() const
{
// How many of the selected rows carry each tag, which is what tells a tag
@@ -5439,7 +5513,7 @@ void MainWindow::editTagsOnSelection()
}
void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
- const QString &description, TagScope tagScope)
+ const QString &description)
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
@@ -5451,12 +5525,10 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
// threadAt(index.row()) mapping silently acted on whichever thread sat at
// that position in the list.
//
- // Message scope by default since item 108: a thread row displays one
- // message, so acting on it acts on that message. Thread scope is what the
- // "Whole thread" actions ask for explicitly.
- const ActionScope scope = tagScope == TagScope::Thread
- ? m_model->scopeFor(rows)
- : m_model->messageScopeFor(rows);
+ // One resolution, per row, from what the row IS (item 177). There is no
+ // scope argument any more: a caller that could choose is what let one
+ // gesture mean two things and forced a second set of actions to exist.
+ const ActionScope scope = m_model->scopeForSelection(rows);
if (scope.isEmpty())
return;
@@ -5613,11 +5685,19 @@ void MainWindow::trashSelected()
if (rows.isEmpty())
return;
- // Message scope, exactly as tagSelected() uses by default: a thread row
- // stands for the ONE message its card displays. Escalating to the thread
- // would move a whole conversation into the trash because the user deleted
- // one reply.
- const ActionScope scope = m_model->messageScopeFor(rows);
+ // Resolved per row, like every other action since item 177. A conversation
+ // row deletes its conversation; a thread of one deletes its message. A
+ // REPLY row never reaches here at all, because Delete is hidden on one:
+ // the user's rule is that a single reply cannot be removed from a thread.
+ const ActionScope scope = m_model->scopeForSelection(rows);
+
+ // Both halves are run, because a selection really can hold one of each and
+ // dropping either would silently delete less than the user asked for. They
+ // travel different routes: an unexpanded conversation's message ids and
+ // paths live only in the database, so the thread half is asynchronous.
+ if (!scope.threadIds.isEmpty())
+ trashThreads(scope.threadIds);
+
if (scope.messageIds.isEmpty())
return;
@@ -5625,7 +5705,7 @@ void MainWindow::trashSelected()
for (const QString &messageId : scope.messageIds)
pathById.insert(messageId, m_model->messageById(messageId).filePath);
- trashMessages(scope.messageIds, pathById, scope.messageCount);
+ trashMessages(scope.messageIds, pathById, scope.messageIds.size());
}
void MainWindow::trashMessages(const QStringList &messageIds,
@@ -5725,34 +5805,8 @@ QString MainWindow::originTagFor(const QString &dbRelativeFolder) const
return QStringLiteral("deleted-from:%1").arg(accountRelative);
}
-QStringList MainWindow::selectedThreadIds() const
+void MainWindow::trashThreads(const QStringList &threadIds)
{
- // A THREAD action on a reply row means that reply's conversation.
- //
- // scopeFor() reports a reply under messageIds and leaves threadIds empty,
- // which is right for the mixed selections it was built for and wrong as
- // the only input to a thread-scoped action: the early return on an empty
- // threadIds made Delete thread do nothing at all when the selected row
- // happened to be a reply. threadFor() resolves either kind of row.
- const QModelIndexList rows =
- m_threadView->selectionModel()->selectedRows();
- QStringList threadIds;
- for (const QModelIndex &index : rows) {
- const QString threadId = m_model->threadFor(index).threadId;
- if (!threadId.isEmpty() && !threadIds.contains(threadId))
- threadIds.append(threadId);
- }
- return threadIds;
-}
-
-void MainWindow::trashSelectedThreads()
-{
- const QModelIndexList rows =
- m_threadView->selectionModel()->selectedRows();
- if (rows.isEmpty())
- return;
-
- const QStringList threadIds = selectedThreadIds();
if (threadIds.isEmpty())
return;
@@ -5794,6 +5848,25 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
const QStringList threadScope = m_pendingThreadScope;
m_pendingThreadScope.clear();
+ if (requestTag == QStringLiteral("reply_thread")) {
+ if (messageIds.isEmpty()) {
+ showTransientStatus(tr("That thread holds no message to answer"));
+ return;
+ }
+ // The NEWEST message, which resolveQuery() puts first: In-Reply-To and
+ // References then land the answer at the END of the conversation, and
+ // the recipients are the ones currently in it rather than whoever
+ // started it.
+ //
+ // ReplyAll and no quoting, at the user's decision. A conversation is
+ // multi-party by definition, so answering one participant of it is the
+ // unusual case and stays available on an individual message; and "we
+ // just add an answer to the thread", so there is nothing to quote.
+ requestMessageForCompose(messageIds.first(),
+ ComposeContext::Kind::ReplyAll, false);
+ return;
+ }
+
if (requestTag == QStringLiteral("empty_trash")) {
confirmAndPurge(messageIds);
return;
@@ -5876,14 +5949,8 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
.arg(tr("Undelete thread")));
}
-void MainWindow::restoreSelectedThreads()
+void MainWindow::untrashThreads(const QStringList &threadIds)
{
- const QModelIndexList rows =
- m_threadView->selectionModel()->selectedRows();
- if (rows.isEmpty())
- return;
-
- const QStringList threadIds = selectedThreadIds();
if (threadIds.isEmpty())
return;
@@ -6042,7 +6109,14 @@ void MainWindow::restoreSelectedFromTrash()
if (rows.isEmpty())
return;
- const ActionScope scope = m_model->messageScopeFor(rows);
+ const ActionScope scope = m_model->scopeForSelection(rows);
+
+ // A conversation row restores its whole conversation, by the same rule
+ // that makes Delete conversation-scoped there: the two are inverses and
+ // must agree about what they act on.
+ if (!scope.threadIds.isEmpty())
+ untrashThreads(scope.threadIds);
+
if (scope.messageIds.isEmpty())
return;
@@ -6057,7 +6131,7 @@ void MainWindow::restoreSelectedFromTrash()
//
// A restore has to be right about the destination or it is worse than
// doing nothing, so it asks the database rather than trusting a view that
- // may be a moment behind. restoreSelectedThreads() already worked this
+ // may be a moment behind. untrashThreads() already worked this
// way; this is the same reasoning applied to the message-scoped path.
m_pendingRestoreIds = scope.messageIds;
QMetaObject::invokeMethod(
@@ -6180,7 +6254,13 @@ void MainWindow::restoreSelected(bool fallbackToInbox)
if (rows.isEmpty())
return;
- const ActionScope scope = m_model->messageScopeFor(rows);
+ const ActionScope scope = m_model->scopeForSelection(rows);
+
+ // The conversation half, for the same reason trashSelected() has one: this
+ // is the undelete direction of the same toggle.
+ if (!scope.threadIds.isEmpty())
+ untrashThreads(scope.threadIds);
+
if (scope.messageIds.isEmpty())
return;
@@ -6346,7 +6426,7 @@ void MainWindow::sendMove(const QStringList &messageIds,
displayRemove.append(tag);
}
// A thread-scoped move already repainted its rows in
- // trashSelectedThreads() / restoreSelectedThreads(), synchronously, before
+ // trashThreads() / untrashThreads(), synchronously, before
// the worker was asked to resolve the threads at all. Repeating it here
// would be harmless but redundant; more importantly the caller there needs
// the repaint to happen WITHOUT a worker round trip, which is the whole
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 6321573..fa413fe 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -112,7 +112,7 @@ public:
/// this is testable with no worker and no database.
///
/// Scope follows the ACTION. The three queues already encode it: a held
- /// thread edit carries thread ids because a `*_thread` action made it,
+ /// thread edit carries thread ids because a CONVERSATION row made it,
/// while a netted tag edit and a held move both carry message ids. Nothing
/// is expanded, and nothing is escalated.
/// Net changes the index holds that a sync has not carried over.
@@ -768,11 +768,23 @@ private:
/// reply from reply-without-quoting, which are the same kind with and
/// without a seeded body.
///
- /// Resolves through ThreadListModel::messageScopeFor(), NOT threadFor(): a
- /// thread row means the one message its card shows. Replying to a thread
- /// is meaningless, a reply answers a message.
+ /// Resolved per ROW since item 177. A message row, whether a reply or a
+ /// thread of one, seeds the composer from that message exactly as before.
+ /// A CONVERSATION row has no one message to answer, so `reply` there means
+ /// "reply to this thread": reply-all, quoting nothing, threaded off the
+ /// conversation's NEWEST message so the answer lands at its end. The other
+ /// four compose actions are hidden on such a row rather than redefined.
void composeReply(ComposeContext::Kind kind, bool quote);
+ /// Starts the conversation reply: asks the worker for the thread's
+ /// messages, and finishes in onThreadMessagesResolved().
+ ///
+ /// A round trip because the newest message's id is not in the model. A
+ /// thread the user never expanded holds no nodes for its replies at all,
+ /// and the summary carries only the FIRST message, which is the one a
+ /// reply must not answer.
+ void replyToThread(const QString &threadId);
+
/// Asks the worker for \p messageId's current file, then opens a composer.
///
/// The round trip is the point. The context is built from the DATABASE and
@@ -835,20 +847,16 @@ private:
const QString &description,
const std::function<void()> &handler);
- /// What a tag action acts on.
+ /// Applies a tag change to whatever the selection stands for.
///
- /// Since item 108 a thread ROW means the one message its card displays, so
- /// Message is the default and Thread is the explicit choice the user makes
- /// through the "Whole thread" submenu. Before that there was no choice:
- /// a thread row always meant the conversation.
- enum class TagScope {
- Message, ///< The message each selected row displays.
- Thread, ///< Every message of each selected row's thread.
- };
-
+ /// There is no scope parameter, and that is item 177: the ROW decides.
+ /// A conversation row means its conversation, any other row means its one
+ /// message, and ThreadListModel::scopeForSelection() is the only place
+ /// that answer is worked out. A caller that could choose is how the same
+ /// gesture came to mean two things, which is what the deleted "Whole
+ /// thread" submenu existed to disambiguate.
void tagSelected(const QStringList &add, const QStringList &remove,
- const QString &description,
- TagScope scope = TagScope::Message);
+ const QString &description);
/// Starts, restarts or cancels the mark-read timer for a newly opened
/// MESSAGE. Cancels outright for one that is not unread, so an already read
@@ -921,32 +929,18 @@ private:
void updateSyncControls();
- /// Opens the tag dialog on the current selection and applies its result.
- ///
- /// The only route to an arbitrary tag: every other tag action writes a
- /// hardcoded name.
- /// The "Whole thread" submenu, built fresh for each parent that needs one.
- ///
- /// A QMenu lives in one menu tree, so the menu bar and the context menu get
- /// their own instance. The actions inside are shared, which is what has to
- /// stay consistent between them.
- QMenu *buildThreadActionsMenu(QWidget *parent);
-
/// Per-tag counts across the selected rows, for the tag dialog.
QHash<QString, int> selectionTagCounts() const;
/// True when every selected row already carries \p tag, which is what a
/// toggle asks before choosing its direction.
///
- /// Under Message scope each row answers about what it STANDS FOR: a reply
- /// row about its message, a thread row about the message its card
- /// displays. Asking a reply's thread makes a toggle one-way, since the
- /// message-scoped write never changes the thread's tags.
- ///
- /// Under Thread scope a row answers about its whole thread, so the
- /// question matches the write the thread actions are about to make.
- bool everySelectedRowHasTag(const QString &tag,
- TagScope scope = TagScope::Message) const;
+ /// Each row answers about what it STANDS FOR, which is the same question
+ /// the write asks: a conversation row about its whole thread, any other
+ /// row about its one message. Asking a reply's thread makes a toggle
+ /// one-way, since the message-scoped write never changes the thread's
+ /// tags and the answer therefore never moves.
+ bool everySelectedRowHasTag(const QString &tag) const;
/// The three-valued version of the question above, which is what a LABEL
/// needs and a toggle's direction does not.
@@ -958,13 +952,37 @@ private:
/// is what happens when a two-valued predicate is asked a three-valued
/// question.
enum class TagPresence { None, Every, Mixed };
- TagPresence selectionTagPresence(
- const QString &tag, TagScope scope = TagScope::Message) const;
+ TagPresence selectionTagPresence(const QString &tag) const;
/// Relabels the unread action, and hides it when the selection has no
/// single state. Called whenever the selection changes.
void refreshUnreadAction();
+ /// Makes every scoped action say what it will act on, and hides the ones
+ /// that cannot mean anything on the selected row (item 177).
+ ///
+ /// Two separate jobs, deliberately in one pass over one selection so the
+ /// label and the visibility cannot disagree. Delete and Archive name the
+ /// thread on a conversation row and are ABSENT on a reply, per the user:
+ /// a single reply cannot be removed from a conversation. Forward and Save
+ /// are the mirror, absent on a conversation row, which shows no message
+ /// to forward and names no file to write.
+ void refreshScopedActionLabels();
+
+ /// Whether every selected row is a conversation, a message, or neither
+ /// because the selection mixes them or is empty.
+ enum class SelectionKind { Empty, Conversations, Messages, Mixed };
+ SelectionKind selectionKind() const;
+
+ /// Whether the selection holds a reply row, which is what hides Delete,
+ /// Restore and Archive (item 177).
+ ///
+ /// Written by refreshScopedActionLabels() and read by
+ /// refreshTrashActions(), which runs after it and owns the same two
+ /// actions' visibility. A flag rather than a second walk over the
+ /// selection, so the two cannot answer differently.
+ bool m_replySelectionHidesDelete = false;
+
/// Hides Delete on mail already in the trash, and Restore on mail that
/// was never there (item 168). Each is offered only where it means
/// something, the same rule refreshUnreadAction() applies to the label.
@@ -979,6 +997,10 @@ private:
/// Delete on exactly the mail a trash view is full of.
bool everySelectedRowIsInATrashFolder() const;
+ /// Opens the tag dialog on the current selection and applies its result.
+ ///
+ /// The only route to an arbitrary tag: every other tag action writes a
+ /// hardcoded name.
void editTagsOnSelection();
/// Set once the user has answered the exit prompt, or once a sync started
@@ -1052,21 +1074,20 @@ private:
int messageCount,
const QStringList &wholeThreadIds = {});
- /// Moves every message of each selected THREAD to its account's trash.
+ /// Moves every message of the named THREADS to their accounts' trash.
///
/// Asynchronous, unlike its message-scoped twin: 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 trashSelectedThreads();
-
- /// The thread ids the selection covers, resolving a reply row to its own
- /// thread. scopeFor() reports a reply under messageIds instead, which left
- /// a thread action on a reply row doing nothing at all.
- QStringList selectedThreadIds() const;
+ ///
+ /// Takes ids rather than reading the selection, because since item 177 the
+ /// conversation half of a delete is one branch of trashSelected() rather
+ /// than a separate action the user could pick.
+ void trashThreads(const QStringList &threadIds);
- /// The inverse of trashSelectedThreads(): moves every message of each
- /// selected thread back where it came from.
- void restoreSelectedThreads();
+ /// The inverse of trashThreads(): moves every message of the named threads
+ /// back where it came from.
+ void untrashThreads(const QStringList &threadIds);
/// Runs the thread-scoped delete once the worker has resolved the
/// threads to messages.
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 1f28973..9fb3e4d 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -1326,6 +1326,13 @@ void NotmuchWorker::resolveQuery(const QString &query,
return;
}
+ // Stated rather than inherited. notmuch's default already is newest-first,
+ // and the move and restore callers do not care about the order at all, but
+ // "reply to this thread" reads the FIRST id as the conversation's newest
+ // message: an unstated default is not something a reply's threading
+ // headers should rest on.
+ notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_NEWEST_FIRST);
+
notmuch_messages_t *raw = nullptr;
if (notmuch_query_search_messages(nmQuery.get(), &raw)
!= NOTMUCH_STATUS_SUCCESS) {
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index ddddf0b..fd4899b 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -1050,73 +1050,6 @@ MessageNode ThreadListModel::messageById(const QString &messageId) const
return {};
}
-ActionScope ThreadListModel::messageScopeFor(
- const QModelIndexList &selection) const
-{
- ActionScope scope;
-
- for (const QModelIndex &index : selection) {
- QString messageId;
- if (isMessageRow(index)) {
- messageId = messageAt(index).messageId;
- } else {
- if (index.row() < 0 || index.row() >= m_threads.size())
- continue;
- // The message the CARD displays, which the query already named.
- // Not the loaded children: a thread the user never expanded still
- // shows its first message, and this must work without one.
- messageId = m_threads.at(index.row()).summary.firstMessageId;
- }
-
- // Skipped rather than widened. Falling back to the thread here would
- // silently act on messages the row does not display, which is the
- // behaviour item 108 removed.
- if (messageId.isEmpty() || scope.messageIds.contains(messageId))
- continue;
-
- scope.messageIds.append(messageId);
- scope.messageCount += 1;
- }
-
- return scope;
-}
-
-ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const
-{
- ActionScope scope;
-
- for (const QModelIndex &index : selection) {
- if (isMessageRow(index)) {
- const MessageNode node = messageAt(index);
- if (node.messageId.isEmpty()
- || scope.messageIds.contains(node.messageId))
- continue;
- scope.messageIds.append(node.messageId);
- scope.messageCount += 1;
- continue;
- }
-
- if (index.row() < 0 || index.row() >= m_threads.size())
- continue;
-
- const ThreadSummary &summary = m_threads.at(index.row()).summary;
- if (scope.threadIds.contains(summary.threadId))
- continue;
-
- scope.threadIds.append(summary.threadId);
-
- // totalCount, not the loaded children: a thread that was never expanded
- // still has all of its messages, and counting only what happens to be
- // on screen would understate what the action does. Floored at 1, since
- // a summary with no count still stands for at least the message that
- // produced it.
- scope.messageCount += qMax(1, summary.totalCount);
- scope.wholeThread = true;
- }
-
- return scope;
-}
-
ActionScope ThreadListModel::scopeForSelection(
const QModelIndexList &selection) const
{
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index fcbb8f5..a528c7e 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -333,40 +333,13 @@ public:
/// expanded thread holds it.
MessageNode messageById(const QString &messageId) const;
- /// Resolves a selection into whole THREADS, for the thread-scoped actions.
- ///
- /// A thread row contributes its thread; a message row still contributes
- /// only itself, since a reply's own row cannot be widened into its
- /// conversation without escalating silently. Mixed selections are honoured
- /// as given: a thread root and an unrelated reply act on that whole thread
- /// and that one message.
- ///
- /// **Not the default any more.** Since item 108 the ordinary actions use
- /// messageScopeFor(); this is what the explicit "whole thread" submenu
- /// resolves through.
- ActionScope scopeFor(const QModelIndexList &selection) const;
-
- /// Resolves a selection into individual MESSAGES, which is what the
- /// ordinary tag actions act on since item 108.
- ///
- /// A thread row contributes the ONE message its card displays, not its
- /// whole conversation. That is `ThreadSummary::firstMessageId`, carried
- /// from the query, so this needs no expansion and no worker round trip.
- /// In the Sent view that field is the first MATCHED message rather than
- /// the thread's opening one, which is right here for the same reason it is
- /// right on the card: both answer "the message this row shows".
- ///
- /// A thread row whose `firstMessageId` is empty contributes nothing. That
- /// is a row the model cannot name a message for, and acting on the whole
- /// thread instead would be the silent escalation this exists to remove.
- ActionScope messageScopeFor(const QModelIndexList &selection) const;
-
/// What a selection means, resolved per row from what that row IS.
///
- /// Replaces the scopeFor()/messageScopeFor() pair, which made the caller
- /// choose the scope and so let one gesture mean two things (item 177). A
- /// conversation row contributes its thread, any other row its message, and
- /// a mixed selection carries both.
+ /// The one answer to the question, and the only one (item 177). It
+ /// replaced a scopeFor()/messageScopeFor() pair that made the CALLER
+ /// choose, which let one gesture mean two things and needed a second set
+ /// of actions to disambiguate. A conversation row contributes its thread,
+ /// any other row its message, and a mixed selection carries both.
ActionScope scopeForSelection(const QModelIndexList &selection) const;
/// The account keys behind a thread's account tags, for item 49's