aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/messagedetailsdialog.cpp153
-rw-r--r--src/messagedetailsdialog.h87
-rw-r--r--src/messageview.cpp55
4 files changed, 247 insertions, 49 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 382f3b8..ee1f621 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -22,6 +22,7 @@ add_library(qtmaildir_lib STATIC
syncmonitor.cpp
threadcidmap.cpp
messageview.cpp
+ messagedetailsdialog.cpp
mainwindow.cpp
querycompleter.cpp
rulequery.cpp
diff --git a/src/messagedetailsdialog.cpp b/src/messagedetailsdialog.cpp
new file mode 100644
index 0000000..058814b
--- /dev/null
+++ b/src/messagedetailsdialog.cpp
@@ -0,0 +1,153 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "messagedetailsdialog.h"
+
+#include <QDialogButtonBox>
+#include <QFontDatabase>
+#include <QGridLayout>
+#include <QLabel>
+#include <QMenu>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+#include "mimeparser.h"
+#include "searchterm.h"
+
+MessageDetailsDialog::MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle(tr("Message details"));
+ setObjectName(QStringLiteral("messageDetailsDialog"));
+
+ buildRows(items);
+
+ auto *layout = new QVBoxLayout(this);
+
+ // Scrollable: a long thread has many rows, and the dialog must not grow
+ // past the screen to show them.
+ auto *scroll = new QScrollArea(this);
+ scroll->setWidgetResizable(true);
+ auto *content = new QWidget(scroll);
+ auto *grid = new QGridLayout(content);
+
+ // A monospaced value keeps a long id or address readable as the record it
+ // is, which is what the text box did well and is worth carrying over.
+ const QFont fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+
+ int gridRow = 0;
+ int lastMessage = -1;
+ for (const HeaderRow &row : std::as_const(m_rows)) {
+ if (items.size() > 1 && row.messageIndex != lastMessage) {
+ lastMessage = row.messageIndex;
+ auto *heading = new QLabel(
+ tr("Message %1 of %2").arg(row.messageIndex + 1)
+ .arg(items.size()),
+ content);
+ heading->setTextFormat(Qt::PlainText);
+ QFont headingFont = heading->font();
+ headingFont.setBold(true);
+ heading->setFont(headingFont);
+ grid->addWidget(heading, gridRow, 0, 1, 2);
+ ++gridRow;
+ }
+
+ auto *label = new QLabel(row.label, content);
+ label->setTextFormat(Qt::PlainText);
+ label->setAlignment(Qt::AlignTop | Qt::AlignLeft);
+ grid->addWidget(label, gridRow, 0);
+
+ // PlainText stated, not inferred. A QLabel guesses under AutoText, and
+ // this value came from a stranger.
+ auto *value = new QLabel(row.value, content);
+ value->setTextFormat(Qt::PlainText);
+ value->setFont(fixed);
+ value->setWordWrap(true);
+ value->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ if (!row.query.isEmpty()) {
+ value->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(value, &QWidget::customContextMenuRequested, this,
+ [this, value, row](const QPoint &pos) {
+ QMenu menu(this);
+ auto *replace = menu.addAction(tr("Search for this"));
+ connect(replace, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, false); });
+ auto *narrow = menu.addAction(tr("Add to search"));
+ connect(narrow, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, true); });
+ menu.exec(value->mapToGlobal(pos));
+ });
+ }
+
+ grid->addWidget(value, gridRow, 1);
+ ++gridRow;
+ }
+
+ grid->setColumnStretch(1, 1);
+ grid->setRowStretch(gridRow, 1);
+ scroll->setWidget(content);
+ layout->addWidget(scroll);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ resize(700, 400);
+}
+
+void MessageDetailsDialog::buildRows(const QList<ThreadRenderItem> &items)
+{
+ for (int i = 0; i < items.size(); ++i) {
+ const ParsedMessage &message = items.at(i).message;
+
+ auto add = [this, i](const QString &field, const QString &label,
+ const QString &value, const QString &query) {
+ if (value.isEmpty())
+ return; // An empty row reads as a rendering fault.
+ m_rows.append({ field, label, value, query, i });
+ };
+
+ add(QStringLiteral("subject"), tr("Subject:"), message.subject,
+ SearchTerm::field(QStringLiteral("subject"), message.subject));
+ add(QStringLiteral("from"), tr("From:"), message.from,
+ SearchTerm::field(QStringLiteral("from"), message.from));
+ add(QStringLiteral("to"), tr("To:"), message.to,
+ SearchTerm::field(QStringLiteral("to"), message.to));
+ add(QStringLiteral("cc"), tr("Cc:"), message.cc,
+ SearchTerm::field(QStringLiteral("cc"), message.cc));
+
+ // The raw header is shown, but the query is a one-day range: a text
+ // match on an RFC 2822 string would match almost nothing.
+ const QDateTime sent = MimeParser::parseDate(message.date);
+ add(QStringLiteral("date"), tr("Date:"), message.date,
+ sent.isValid() ? SearchTerm::onDate(sent.date()) : QString());
+
+ // Shown but not searchable: an id names one message, and the thread
+ // holding it is already on screen.
+ add(QString(), tr("Message-Id:"), message.messageId, QString());
+ }
+}
+
+void MessageDetailsDialog::requestSearch(const HeaderRow &row, bool extend)
+{
+ if (row.query.isEmpty())
+ return;
+ emit searchRequested(row.query, extend);
+}
diff --git a/src/messagedetailsdialog.h b/src/messagedetailsdialog.h
new file mode 100644
index 0000000..f865913
--- /dev/null
+++ b/src/messagedetailsdialog.h
@@ -0,0 +1,87 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QList>
+#include <QString>
+
+#include "htmlbuilder.h"
+
+/// One header of one message, as shown and as searched for.
+///
+/// The query is built when the row is, from the parsed value, so nothing has
+/// to parse displayed text back into structure. That is the whole reason this
+/// dialog stopped being a text box.
+struct HeaderRow
+{
+ /// notmuch's field name, or empty for a header with no searchable form.
+ /// Wire format, never translated.
+ QString field;
+
+ /// Translated label shown at the start of the row, e.g. "From:".
+ QString label;
+
+ /// The header's value, verbatim and untrusted.
+ QString value;
+
+ /// The finished query, empty when the header has no searchable form.
+ QString query;
+
+ /// Which message of the thread this row belongs to, zero-based.
+ int messageIndex = 0;
+};
+
+/// The full headers of every message in a thread, read-only.
+///
+/// Rows rather than one text box, so a value can carry its own context menu
+/// without anything parsing rendered text back into structure. The user also
+/// asked not to be shown a text box.
+///
+/// **Every value label is explicitly `Qt::PlainText`.** This replaced a
+/// `QPlainTextEdit` whose plain-textness was a security property rather than a
+/// style: header values come from strangers, and plain text cannot interpret
+/// markup, so there is nothing to escape and nothing that can render. A QLabel
+/// guesses under `Qt::AutoText`, so stating the format is what preserves that.
+class MessageDetailsDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent = nullptr);
+
+ /// The rows on display, in order. Exposed for testing without rendering.
+ QList<HeaderRow> rows() const { return m_rows; }
+
+ /// Emits searchRequested for `row`, or nothing when the row carries no
+ /// searchable query. The menu entries call this; a test can too, without
+ /// popping a menu.
+ void requestSearch(const HeaderRow &row, bool extend);
+
+signals:
+ /// The user chose a search from a row's menu. `extend` narrows the current
+ /// query rather than replacing it.
+ void searchRequested(const QString &query, bool extend);
+
+private:
+ /// Builds the rows from the thread, one group per message.
+ void buildRows(const QList<ThreadRenderItem> &items);
+
+ QList<HeaderRow> m_rows;
+};
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 5f7c9d2..fbd43d6 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -22,8 +22,6 @@
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
-#include <QFontDatabase>
-#include <QPlainTextEdit>
#include <QDir>
#include <QBuffer>
#include <QFileDialog>
@@ -50,6 +48,7 @@
#include "cidschemehandler.h"
#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
#include "requestinterceptor.h"
#include "searchterm.h"
#include "tagstrip.h"
@@ -616,53 +615,11 @@ void MessageView::showDetailsDialog()
if (m_items.isEmpty())
return;
- QDialog dialog(this);
- dialog.setWindowTitle(tr("Message details"));
-
- auto *layout = new QVBoxLayout(&dialog);
-
- auto *details = new QPlainTextEdit(&dialog);
- details->setReadOnly(true);
- // A monospaced font keeps a long Received chain readable as the wrapped
- // record it is.
- details->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
- details->setLineWrapMode(QPlainTextEdit::NoWrap);
-
- // setPlainText, and a QPlainTextEdit rather than a label: this dialog shows
- // header values verbatim, and those come from strangers. Plain text cannot
- // interpret markup, so there is nothing here to escape and nothing that
- // could render.
- QString text;
- for (int i = 0; i < m_items.size(); ++i) {
- const ParsedMessage &message = m_items.at(i).message;
-
- if (i > 0)
- text += QLatin1Char('\n');
- if (m_items.size() > 1)
- text += tr("--- Message %1 of %2 ---")
- .arg(i + 1).arg(m_items.size()) + QLatin1Char('\n');
-
- auto line = [&text](const QString &label, const QString &value) {
- if (!value.isEmpty())
- text += label + QLatin1Char(' ') + value + QLatin1Char('\n');
- };
-
- line(tr("Subject:"), message.subject);
- line(tr("From:"), message.from);
- line(tr("To:"), message.to);
- line(tr("Cc:"), message.cc);
- line(tr("Date:"), message.date);
- line(tr("Message-Id:"), message.messageId);
- }
- details->setPlainText(text);
-
- layout->addWidget(details);
-
- auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
- connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
- layout->addWidget(buttons);
-
- dialog.resize(700, 400);
+ MessageDetailsDialog dialog(m_items, this);
+ // The dialog's searches are the pane's searches: one signal reaches the
+ // window whichever surface the user used.
+ connect(&dialog, &MessageDetailsDialog::searchRequested,
+ this, &MessageView::searchRequested);
dialog.exec();
}