From f934029d3333396bde6ec306ab300641929c7cdc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 28 Aug 2026 17:33:31 +0200 Subject: feat: treat a mixed conversation's unread state as unread Item 112 hid the toggle whenever the selection disagreed, because a union is not a state and no honest label existed for it. That was affordable because the "Whole thread" submenu sat beside it carrying two absolute entries, which worked whatever the mix. Item 177 deletes that submenu: the row decides the scope, so a second set of actions is a second answer to a settled question. Hiding the toggle then leaves the commonest conversation in the mailbox with no key at all. The rule is a catch-all instead. Any unread message, a mixed conversation included, reads "Mark thread as read" and marks every message read; only a fully read selection reads "Mark thread as unread". Two presses therefore reach either state from anywhere, which is what makes one key enough. The write direction moves with the label. Computing it from everySelectedRowHasTag() while the label promised "read" would mark a mixed conversation unread, which is the item 112 report happening again from the other end; the mutation putting that back fails the new test. The three-valued selectionTagPresence() is unchanged and still asked, since Every and Mixed differ for other callers. Only this label collapses them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEK9z5D3oa1nVmJ6xpQhBs --- src/mainwindow.cpp | 54 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 22 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7d9cfbc..c8aa811 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1763,7 +1763,14 @@ void MainWindow::registerActions() // comment said the direction came from the current row while the // change applied to the whole selection, which is the same split that // makes a mixed selection land in two states. - const bool unread = everySelectedRowHasTag(QStringLiteral("unread")); + // + // ANY unread rather than EVERY, which is item 177's catch-all rule and + // must be the same question refreshUnreadAction() asks for the label: + // a label promising "Mark thread read" over a write computed from + // Every would mark a mixed conversation UNREAD, which is the item 112 + // report happening again from the other end. + const bool unread = selectionTagPresence(QStringLiteral("unread")) + != TagPresence::None; // An explicit toggle overrides the automatic one. Without this, marking // a thread unread by hand would be undone a moment later by a timer @@ -3669,16 +3676,21 @@ void MainWindow::refreshTrashActions() 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. + // Item 112 hid this entry whenever the selection disagreed, because a + // union is not a state and no honest label existed. The route out was the + // "Whole thread" submenu, whose two entries were absolute rather than a + // toggle. Item 177 deleted that submenu: the ROW decides the scope, so a + // second set of actions was a second answer to a settled question. + // + // With one key left, hiding on disagreement leaves the commonest + // conversation in the mailbox with no key at all, so the rule is a + // catch-all instead: ANY unread message reads "Mark thread read" and marks + // every message read; only a fully read selection reads "Mark thread + // unread". Mixed is not a special case, it is the ordinary one. // - // 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. + // That keeps one key sufficient, which is what the hidden case cost. Two + // presses reach either state from anywhere: mark read collapses the mix to + // a state, and the second press toggles out of it. auto *action = m_actions.value(QStringLiteral("toggle_unread")); if (!action) return; @@ -3690,9 +3702,15 @@ void MainWindow::refreshUnreadAction() const bool namesTheThread = kind == SelectionKind::Conversations || kind == SelectionKind::Mixed; - switch (selectionTagPresence(QStringLiteral("unread"))) { - case TagPresence::Every: - action->setVisible(true); + // Mixed joins Every rather than hiding: both mean "something here is + // unread", which is the question the direction actually turns on. The + // three-valued answer is still what is asked, because Every and Mixed + // differ for other callers; only this label collapses them. + const bool anyUnread = + selectionTagPresence(QStringLiteral("unread")) != TagPresence::None; + + action->setVisible(true); + if (anyUnread) { action->setText(namesTheThread ? tr("Mark thread as &read") : tr("Mark as &read")); action->setStatusTip( @@ -3700,9 +3718,7 @@ void MainWindow::refreshUnreadAction() ? 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); + } else { action->setText(namesTheThread ? tr("Mark thread as &unread") : tr("Mark as &unread")); action->setStatusTip( @@ -3710,12 +3726,6 @@ void MainWindow::refreshUnreadAction() ? 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 - // than disabled, at the user's choice. - action->setVisible(false); - break; } } -- cgit v1.2.3