summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/messageview.cpp72
-rw-r--r--src/messageview.h37
-rw-r--r--tests/test_messageview.cpp99
3 files changed, 207 insertions, 1 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp
index d2be380..8a1976d 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -30,6 +30,7 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
+#include <QMenu>
#include <QMouseEvent>
#include <QPushButton>
#include <QStandardPaths>
@@ -50,6 +51,7 @@
#include "cidschemehandler.h"
#include "htmlbuilder.h"
#include "requestinterceptor.h"
+#include "searchterm.h"
#include "tagstrip.h"
#include "threadcidmap.h"
#include "version.h"
@@ -163,6 +165,12 @@ MessageView::MessageView(QWidget *parent)
m_headerLabel->setWordWrap(true);
m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ // Item 85: the header's values are searchable. CustomContextMenu rather
+ // than an action list, since the entries depend on what is displayed.
+ m_headerLabel->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(m_headerLabel, &QWidget::customContextMenuRequested,
+ this, &MessageView::showHeaderContextMenu);
+
// To the right of the header, per the user's decision: the summary answers
// "who is this from", this answers "what actually happened to it". A button
// and not only a shortcut, since "everything needs a memorized key" is the
@@ -428,6 +436,11 @@ QString MessageView::headerMark(Marks::Mark mark) const
void MessageView::updateHeader()
{
+ // A stale offer list must not survive either exit: the early return below
+ // leaves nothing on screen to search, and the normal path rebuilds it from
+ // scratch a few lines down.
+ m_headerOffers.clear();
+
if (m_items.isEmpty()) {
m_headerLabel->clear();
m_detailsButton->hide();
@@ -440,6 +453,30 @@ void MessageView::updateHeader()
// Re: prefixes that add nothing.
const QString subject = m_items.first().message.subject;
+ // Collected by the pass that renders the label, from the same values, so
+ // nothing has to parse the rendered markup back into structure.
+ auto elided = [](const QString &value) {
+ constexpr int kMaxLabel = 40;
+ return value.size() > kMaxLabel
+ ? value.left(kMaxLabel) + QStringLiteral("...")
+ : value;
+ };
+
+ auto offer = [this](const QString &label, const QString &query) {
+ if (query.isEmpty())
+ return;
+ m_headerOffers.append({ label, query });
+ };
+
+ offer(tr("subject \"%1\"").arg(elided(subject)),
+ SearchTerm::field(QStringLiteral("subject"), subject));
+
+ const QDateTime sent = MimeParser::parseDate(m_items.first().message.date);
+ if (sent.isValid()) {
+ offer(tr("mail from %1").arg(sent.date().toString(Qt::ISODate)),
+ SearchTerm::onDate(sent.date()));
+ }
+
// Item 70's marks, beside the subject and OUTSIDE the message area. The
// user asked for these two only: whether the thread is flagged and whether
// it carries an attachment, which are the two states worth knowing before
@@ -489,6 +526,16 @@ void MessageView::updateHeader()
row(tr("From:"), message.from);
row(tr("To:"), message.to);
row(tr("Cc:"), message.cc);
+
+ // Only here, sharing the condition with the header's own display: for
+ // a real thread these differ message to message, and the details
+ // dialog is where they are unambiguous.
+ offer(tr("sender %1").arg(elided(message.from)),
+ SearchTerm::field(QStringLiteral("from"), message.from));
+ offer(tr("recipient %1").arg(elided(message.to)),
+ SearchTerm::field(QStringLiteral("to"), message.to));
+ offer(tr("copied to %1").arg(elided(message.cc)),
+ SearchTerm::field(QStringLiteral("cc"), message.cc));
} else {
text += QStringLiteral("<br><small>%1</small>")
.arg(tr("%n message(s) in thread", "", m_items.size()));
@@ -497,6 +544,31 @@ void MessageView::updateHeader()
m_headerLabel->setText(text);
}
+void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers)
+{
+ for (const SearchOffer &entry : offers) {
+ auto *sub = menu->addMenu(tr("Search for %1").arg(entry.label));
+
+ auto *replace = sub->addAction(tr("Search for this"));
+ connect(replace, &QAction::triggered, this,
+ [this, entry]() { emit searchRequested(entry.query, false); });
+
+ auto *narrow = sub->addAction(tr("Add to search"));
+ connect(narrow, &QAction::triggered, this,
+ [this, entry]() { emit searchRequested(entry.query, true); });
+ }
+}
+
+void MessageView::showHeaderContextMenu(const QPoint &pos)
+{
+ if (m_headerOffers.isEmpty())
+ return;
+
+ QMenu menu(this);
+ addSearchEntries(&menu, m_headerOffers);
+ menu.exec(m_headerLabel->mapToGlobal(pos));
+}
+
void MessageView::showDetailsDialog()
{
if (m_items.isEmpty())
diff --git a/src/messageview.h b/src/messageview.h
index 422869a..26ed216 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -25,8 +25,10 @@
#include "htmlbuilder.h"
#include "marks.h"
#include "mimeparser.h"
+#include "searchterm.h"
class QLabel;
+class QMenu;
class QPushButton;
class QWebEngineView;
class QWebEngineProfile;
@@ -127,6 +129,17 @@ public:
/// is rendered or the notice is hidden.
QString staleMessageId() const { return m_staleMessageId; }
+ /// What the header can be searched for, given what it is currently showing.
+ ///
+ /// From, To and Cc appear only for a single-message thread, which is
+ /// exactly when the header displays them: for a real thread the recipient
+ /// differs message to message and the header says only the subject and the
+ /// count. The menu must never offer a value the header is not stating.
+ ///
+ /// The values come from the same pass that renders the label, never from
+ /// parsing it back: rich text does not survive a second parse.
+ QList<SearchOffer> headerSearchOffers() const { return m_headerOffers; }
+
public slots:
void toggleHtml();
void loadRemoteContent();
@@ -158,6 +171,18 @@ signals:
void staleThreadRecoveryRequested(const QString &threadId,
const QString &messageId);
+ /// The user chose a search from one of the pane's context menus.
+ ///
+ /// `extend` narrows the current query rather than replacing it. The view
+ /// does not know what the query bar holds and must not: the window owns
+ /// that field and does the combining.
+ ///
+ /// Separate from queryRequested(), which carries a gate against a link in
+ /// a rendered document driving the thread list. These menus are chrome
+ /// built by our own code from values we extracted, so they need no gate,
+ /// and widening the existing signal would change what that gate protects.
+ void searchRequested(const QString &query, bool extend);
+
protected:
/// Turns Ctrl+wheel over the body into zoom, and Ctrl+middle-click into a
/// reset. Both events are delivered to the web view's internal QQuickWidget
@@ -207,6 +232,15 @@ private:
/// Every attachment in the thread, in the order the messages render.
QList<Attachment> allAttachments() const;
+ /// Builds and pops the header's menu at `pos`, in the label's coordinates.
+ void showHeaderContextMenu(const QPoint &pos);
+
+ /// Appends a "Search for ..." submenu per offer, each holding the replace
+ /// and the narrow operation.
+ ///
+ /// Shared with the web view's menu in a later task so the two cannot grow
+ /// different wording or a different pair of operations.
+ void addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers);
QList<ThreadRenderItem> m_items;
bool m_preferHtml = true;
@@ -232,4 +266,7 @@ private:
QPushButton *m_detailsButton = nullptr;
QWidget *m_attachmentBar = nullptr;
TagStrip *m_tagStrip = nullptr;
+
+ /// Populated by updateHeader(), consumed by the header's context menu.
+ QList<SearchOffer> m_headerOffers;
};
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index e94fb86..6403308 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -49,6 +49,9 @@ private slots:
void detailsDialogIsOfferedForEveryThread();
void placeholderRendersAndReportsItself();
void aMessageBodyCannotRunAQuery();
+ void headerOffersSubjectDateAndSenderForOneMessage();
+ void headerOffersNoSenderForARealThread();
+ void headerOffersNothingForAnAbsentField();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -372,7 +375,7 @@ static ThreadRenderItem oneMessage()
message.to = QStringLiteral("Recipient <recipient@example.org>");
message.cc = QStringLiteral("Copied <copied@example.org>");
message.subject = QStringLiteral("Quarterly report");
- message.date = QStringLiteral("Mon, 4 Aug 2026 09:00:00 +0200");
+ message.date = QStringLiteral("Tue, 4 Aug 2026 09:00:00 +0200");
message.plainBody = QStringLiteral("body");
ThreadRenderItem item;
@@ -566,5 +569,99 @@ void TestMessageView::aMessageBodyCannotRunAQuery()
QVERIFY(queries.isEmpty());
}
+void TestMessageView::headerOffersSubjectDateAndSenderForOneMessage()
+{
+ MessageView view;
+ view.showThread({ oneMessage() });
+
+ const QList<SearchOffer> offers = view.headerSearchOffers();
+
+ QStringList queries;
+ for (const SearchOffer &offer : offers)
+ queries << offer.query;
+ const QString shown = queries.join(QStringLiteral(" | "));
+
+ QVERIFY2(queries.contains(QStringLiteral("subject:\"Quarterly report\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("from:\"Sender <sender@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("to:\"Recipient <recipient@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("cc:\"Copied <copied@example.org>\"")),
+ qPrintable(shown));
+
+ // The date is offered as a one-day range. Qt::RFC2822Date checks the
+ // weekday against the date, so a fixture with the wrong day silently
+ // produces no offer at all: 2026-08-04 is a Tuesday.
+ QVERIFY2(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")),
+ qPrintable(shown));
+
+ // Every offer carries a label the menu shows, naming the value so the user
+ // can see what they are about to search for.
+ for (const SearchOffer &offer : offers) {
+ QVERIFY(!offer.label.isEmpty());
+ QVERIFY(!offer.query.isEmpty());
+ }
+}
+
+void TestMessageView::headerOffersNoSenderForARealThread()
+{
+ // The header shows From/To/Cc only for a single-message thread, because a
+ // thread's recipient differs message to message. The menu shares that
+ // condition: it must never offer a value the header is not stating.
+ ThreadRenderItem first = oneMessage();
+ ThreadRenderItem second = oneMessage();
+ second.message.from = QStringLiteral("Recipient <recipient@example.org>");
+ second.message.to = QStringLiteral("Sender <sender@example.org>");
+
+ MessageView view;
+ view.showThread({ first, second });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // THE GUARD. A test asserting only that something is absent passes against
+ // no implementation whatever. Subject and date must still be offered,
+ // which proves the list was built before the absences below mean anything.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+ QVERIFY(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")));
+
+ for (const QString &query : queries) {
+ QVERIFY2(!query.startsWith(QStringLiteral("from:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("to:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("cc:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ }
+}
+
+void TestMessageView::headerOffersNothingForAnAbsentField()
+{
+ // cc:"" parses cleanly and matches nothing, so an entry built from an
+ // empty header would look enabled and silently do nothing.
+ ThreadRenderItem item = oneMessage();
+ item.message.cc.clear();
+
+ MessageView view;
+ view.showThread({ item });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // Guard first, then the absence.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+
+ for (const QString &query : queries)
+ QVERIFY(!query.startsWith(QStringLiteral("cc:")));
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"