aboutsummaryrefslogtreecommitdiffstats
path: root/src/messageview.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 10:39:47 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:41 +0200
commitf762e4ca0051a7a34123b5a526696f2feb5c6e03 (patch)
treeaf4f3932946ae8937dd8f81ddaf95686e37105ca /src/messageview.cpp
parent1a8fdb48140cb40243bd46bff94d39f610d70ef3 (diff)
downloadqtmaildir-f762e4ca0051a7a34123b5a526696f2feb5c6e03.tar.gz
qtmaildir-f762e4ca0051a7a34123b5a526696f2feb5c6e03.zip
feat(message): show From/To/Cc and add a details dialog
MimeParser has filled To and Cc all along and HtmlBuilder simply never interpolated them, so both were parsed on every message and then discarded. The header strip showed the subject and a message count and nothing else, which is item 2 of the usability backlog. The header now adapts to what it can say honestly. A thread holding one message shows From, To and Cc under the subject, where every field is unambiguous. A thread holding several keeps showing the subject and the count alone: the recipient differs message to message, and once the user has replied there is no single address the thread is addressed to, so naming one would be a guess presented as a fact. Per-message detail is what the dialog is for. That dialog lists Subject, From, To, Cc, Date and Message-Id for every message, numbered when there is more than one, in a read-only plain-text widget. Plain text is the security decision, not a stylistic one: these values come from strangers and the dialog exists to show them verbatim, so the format that cannot interpret markup is the right one. The header label is RichText and every value interpolated into it is escaped, since an unescaped From injects into the application's own chrome rather than into the sandboxed page. Reached by a Details... button beside the subject and by Ctrl+Shift+D. Both, because a shortcut alone restates the complaint this backlog opened with. The binding is shifted because Ctrl+D is delete, and the destructive action keeps the key it already had rather than being moved to make room. An empty Cc omits its row instead of printing a label with nothing after it. Both header shapes were rendered to PNG and inspected, not only asserted. The new button also exposed a latent flaw in an older test: attachmentButtonLabels() identified attachment buttons by excluding the one other button's label, so it counted the details button as an attachment as soon as one existed. It now finds the bar by object name and reads only its children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/messageview.cpp')
-rw-r--r--src/messageview.cpp115
1 files changed, 110 insertions, 5 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 4423791..d7167bf 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -22,6 +22,8 @@
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
+#include <QFontDatabase>
+#include <QPlainTextEdit>
#include <QDir>
#include <QFileDialog>
#include <QHBoxLayout>
@@ -124,6 +126,22 @@ MessageView::MessageView(QWidget *parent)
m_headerLabel->setWordWrap(true);
m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ // 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
+ // complaint this whole backlog started from.
+ m_detailsButton = new QPushButton(tr("Details..."), this);
+ m_detailsButton->setObjectName(QStringLiteral("messageDetails"));
+ m_detailsButton->setToolTip(tr("Show the full headers of every message"));
+ connect(m_detailsButton, &QPushButton::clicked,
+ this, &MessageView::showDetailsDialog);
+ m_detailsButton->hide();
+
+ auto *headerRow = new QHBoxLayout;
+ headerRow->addWidget(m_headerLabel, 1);
+ // Top-aligned so it stays put as the header grows to four rows.
+ headerRow->addWidget(m_detailsButton, 0, Qt::AlignTop);
+
m_blockedLabel = new QLabel(tr("Remote content blocked"), this);
m_loadRemoteButton = new QPushButton(tr("Load remote content"), this);
connect(m_loadRemoteButton, &QPushButton::clicked,
@@ -135,6 +153,7 @@ MessageView::MessageView(QWidget *parent)
blockedRow->addStretch();
m_attachmentBar = new QWidget(this);
+ m_attachmentBar->setObjectName(QStringLiteral("attachmentBar"));
new QHBoxLayout(m_attachmentBar);
// Tags live under the message rather than in the thread list, where
@@ -143,7 +162,7 @@ MessageView::MessageView(QWidget *parent)
m_tagStrip->hide();
auto *layout = new QVBoxLayout(this);
- layout->addWidget(m_headerLabel);
+ layout->addLayout(headerRow);
layout->addLayout(blockedRow);
layout->addWidget(m_view, 1);
layout->addWidget(m_attachmentBar);
@@ -260,17 +279,103 @@ void MessageView::updateHeader()
{
if (m_items.isEmpty()) {
m_headerLabel->clear();
+ m_detailsButton->hide();
return;
}
+ m_detailsButton->show();
+
// The thread's subject comes from its first message; later replies carry
// Re: prefixes that add nothing.
const QString subject = m_items.first().message.subject;
- m_headerLabel->setText(
- QStringLiteral("<b>%1</b><br><small>%2</small>")
- .arg(subject.toHtmlEscaped(),
- tr("%n message(s) in thread", "", m_items.size())));
+ QString text = QStringLiteral("<b>%1</b>").arg(subject.toHtmlEscaped());
+
+ // The header adapts to what it can say honestly. From, To and Cc are
+ // per-message, and the pane shows a whole thread, so they are only
+ // unambiguous when the thread holds exactly one message. For a real thread
+ // the recipient differs message to message (once the user replies, one is
+ // addressed to them and the next to the other party), and neither the union
+ // nor the intersection is "the" recipient. Rather than pick one or compute
+ // a participants list, the thread case says only the subject and the count,
+ // and the per-message detail belongs to the dialog.
+ if (m_items.size() == 1) {
+ const ParsedMessage &message = m_items.first().message;
+
+ // Every value here is attacker-controlled and the label is RichText, so
+ // escaping is not cosmetic: an unescaped From injects markup into the
+ // application's own chrome rather than into the sandboxed page.
+ auto row = [&text](const QString &label, const QString &value) {
+ if (value.isEmpty())
+ return; // An empty row reads as a rendering fault.
+ text += QStringLiteral("<br><small>%1 %2</small>")
+ .arg(label.toHtmlEscaped(), value.toHtmlEscaped());
+ };
+
+ row(tr("From:"), message.from);
+ row(tr("To:"), message.to);
+ row(tr("Cc:"), message.cc);
+ } else {
+ text += QStringLiteral("<br><small>%1</small>")
+ .arg(tr("%n message(s) in thread", "", m_items.size()));
+ }
+
+ m_headerLabel->setText(text);
+}
+
+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);
+ dialog.exec();
}
void MessageView::render()