diff options
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 2 | ||||
| -rw-r--r-- | src/messageview.cpp | 8 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 96 |
3 files changed, 105 insertions, 1 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 5b8d2ce..ba0293f 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -219,7 +219,7 @@ taking that too literally. | 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction | | 148 | Ctrl+W does not close the composer | discoverability | XS | open, 2026-08-23, from the notes. Verified: nothing binds `Ctrl+W` anywhere, and the composer has no close action of its own. Belongs with item 21's table rather than bound in isolation | | 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased. `quote_position` names where the QUOTE goes, so the cursor follows the reply and not the buffer: `End` under Above, `Start` under Below, where a shared `Start` had put it on the `On ... wrote:` line. The existing `theQuotePositionDecidesWhereTheQuoteLands` passed throughout, because the quote WAS in the right place | -| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | open, 2026-08-24, from the notes. Observed in All accounts: a receive-only message raised the ribbon, and selecting a sendable account's mail left it visible. Verified: `MessageView::clear()` resets every other transient bar by hand (blocked-content label, stale notice, attachment bar) and does NOT touch `m_receiveOnlyRibbon`, which only `setReceiveOnlyAccount()` hides. So any path that repaints the pane without reaching `MainWindow::updateComposeActions()` strands it. Belongs in `clear()`, beside the other three, rather than as a new call site | +| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason | | 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | open, 2026-08-24, from the notes. The user's design: a yellow ground for warnings (the item 150 ribbon), a blue one for actions (remote content blocked), with the action's button right-aligned. Both are plain `QLabel`s today. Colour alone is not enough per CLAUDE.md's palette rule, and the pane is theme-aware, so the two grounds need light and dark values rather than one literal | | 152 | Signatures are not managed at all | v2 | ? | open, 2026-08-24, from the notes, asked for as a brainstorm rather than a build. Nothing in `[compose]` or `[account.*]` mentions a signature, so this is unspecified: per-account text, where it sits relative to the quote, and whether the HTML part gets its own form are all open. Needs the user to say what they picture before it can be sized | diff --git a/src/messageview.cpp b/src/messageview.cpp index 5682858..aa887ae 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -540,6 +540,14 @@ void MessageView::clear() // button offers to recover a thread the user has navigated away from. setStaleThread(QString(), QString()); + // The ribbon explains ONE message's account, so it goes with the message + // for the same reason as the two bars above. Only setReceiveOnlyAccount() + // hid it, which every SELECTION change reaches, so the ribbon survived + // every other route to a blank pane: clear_pane, clear_selection, a new + // query and a multi-row selection all blanked the message underneath it + // and left it contradicting the Reply button beside it. + m_receiveOnlyRibbon->hide(); + // clear() does not go through render(), so the bar has to be emptied // here or the previous thread's attachments stay offered. rebuildAttachmentBar(); diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 91fbd44..9e4543c 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -257,6 +257,7 @@ private slots: void theMailRootComesFromTheConfigNotTheIndex(); void replyIsDisabledOnAReceiveOnlyAccountsMail(); void theReceiveOnlyRibbonNamesTheAccount(); + void theReceiveOnlyRibbonGoesWithTheMessageThatRaisedIt(); void replyIsEnabledOnASendingAccountsMail(); void composeIsDisabledOnlyWhenNoAccountCanSend(); void quittingWithACleanComposerAsksNothing(); @@ -8431,6 +8432,101 @@ void TestMainWindow::theReceiveOnlyRibbonNamesTheAccount() QCOMPARE(ribbon->textFormat(), Qt::PlainText); } +void TestMainWindow::theReceiveOnlyRibbonGoesWithTheMessageThatRaisedIt() +{ + // The ribbon explains ONE message, so it must not outlive it. Observed in + // All accounts: receive-only mail raised it, and selecting mail from an + // account that can send left it on screen contradicting the live Reply + // button beside it. + WorkerComposeFixture fixture; + QVERIFY(fixture.backed.fixture().addMessage( + QStringLiteral("listsonly/inbox"), QStringLiteral("ro@example.org"), + QStringLiteral("Receive only"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY(fixture.backed.fixture().addMessage( + QStringLiteral("work/inbox"), QStringLiteral("rw@example.org"), + QStringLiteral("Can send"), QStringLiteral("sender@example.org"), + QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"), + QStringLiteral("Body text."))); + QVERIFY2(fixture.backed.buildWithAccounts( + { { QStringLiteral("listsonly"), QStringLiteral("listsonly"), + QString(), QString(), QStringLiteral("you@example.org") }, + { QStringLiteral("work"), QStringLiteral("work"), + QString(), QStringLiteral("/bin/true"), + QStringLiteral("you@example.org") } }), + qPrintable(fixture.backed.error())); + + MainWindow window(fixture.backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *ribbon = + window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon")); + QVERIFY(model && view && queryEdit && ribbon); + + // Both messages in one list, which is the All accounts view the defect was + // seen in. The mail root has to have arrived too: the ribbon is decided by + // which account owns the message, which cannot be answered without it. + queryEdit->setText(QStringLiteral("tag:inbox")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2 + && !window.mailRootForTesting().isEmpty(), + 15000); + + // Find each row by subject rather than by position: the sort order is not + // what is under test, and asserting on it would make this fail for a + // reason that has nothing to do with the ribbon. + QModelIndex receiveOnlyRow; + QModelIndex sendingRow; + for (int row = 0; row < model->rowCount(QModelIndex()); ++row) { + const QModelIndex index = model->index(row, 0, QModelIndex()); + const QString subject = model->threadFor(index).subject; + if (subject == QStringLiteral("Receive only")) + receiveOnlyRow = index; + else if (subject == QStringLiteral("Can send")) + sendingRow = index; + } + QVERIFY2(receiveOnlyRow.isValid() && sendingRow.isValid(), + "the two seeded messages are not both in the list"); + + view->setCurrentIndex(receiveOnlyRow); + QTRY_VERIFY_WITH_TIMEOUT(!ribbon->isHidden(), 15000); + + // Straight from one to the other, with no deselection in between. This + // half already worked: a selection change reaches updateComposeActions(). + view->setCurrentIndex(sendingRow); + QTRY_VERIFY_WITH_TIMEOUT(ribbon->isHidden(), 15000); + QVERIFY2(ribbon->isHidden(), + "the ribbon stayed up on mail from an account that can send"); + + // The half that did not: blanking the pane by any route that is not a + // selection change. MessageView::clear() resets the blocked-content bar, + // the stale notice and the attachment bar by hand, and forgot this one, so + // the ribbon outlived the message it explains. + view->setCurrentIndex(receiveOnlyRow); + QTRY_VERIFY_WITH_TIMEOUT(!ribbon->isHidden(), 15000); + + window.findChild<QAction *>(QStringLiteral("clear_pane"))->trigger(); + QVERIFY2(ribbon->isHidden(), + "the ribbon survived clear_pane, over a blank message pane"); + + // Away and back, not straight back: clear_pane leaves the receive-only row + // CURRENT, so re-selecting it emits no change and the ribbon would never + // be re-raised. That is the view's behaviour and not the defect under test. + view->setCurrentIndex(sendingRow); + QTRY_VERIFY_WITH_TIMEOUT(ribbon->isHidden(), 15000); + view->setCurrentIndex(receiveOnlyRow); + QTRY_VERIFY_WITH_TIMEOUT(!ribbon->isHidden(), 15000); + + queryEdit->setText(QStringLiteral("tag:inbox and subject:\"Can send\"")); + queryEdit->returnPressed(); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + QVERIFY2(ribbon->isHidden(), + "the ribbon survived a new query that blanked the pane"); +} + void TestMainWindow::composeIsDisabledOnlyWhenNoAccountCanSend() { // An installation with no send_command anywhere is a valid read-only |
