Propagate color of selected messages too
[trojita.git] / src / Gui / ComposeWidget.cpp
blobf19071c8fa71d77eb5c95cd741dfa13b2703c1c2
1 /* Copyright (C) 2006 - 2014 Jan Kundrát <jkt@flaska.net>
2 Copyright (C) 2012 Peter Amidon <peter@picnicpark.org>
3 Copyright (C) 2013 - 2014 Pali Rohár <pali.rohar@gmail.com>
5 This file is part of the Trojita Qt IMAP e-mail client,
6 http://trojita.flaska.net/
8 This program is free software; you can redistribute it and/or
9 modify it under the terms of the GNU General Public License as
10 published by the Free Software Foundation; either version 2 of
11 the License or (at your option) version 3 or any later version
12 accepted by the membership of KDE e.V. (or its successor approved
13 by the membership of KDE e.V.), which shall act as a proxy
14 defined in Section 14 of version 3 of the license.
16 This program is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with this program. If not, see <http://www.gnu.org/licenses/>.
24 #include <QAbstractProxyModel>
25 #include <QBuffer>
26 #include <QDesktopWidget>
27 #include <QFileDialog>
28 #include <QGraphicsOpacityEffect>
29 #include <QKeyEvent>
30 #include <QMenu>
31 #include <QMessageBox>
32 #include <QProgressDialog>
33 #include <QPropertyAnimation>
34 #include <QSettings>
35 #include <QTimer>
36 #include <QToolButton>
37 #include <QUrlQuery>
39 #include "ui_ComposeWidget.h"
40 #include "Composer/MessageComposer.h"
41 #include "Composer/ReplaceSignature.h"
42 #include "Composer/Mailto.h"
43 #include "Composer/SenderIdentitiesModel.h"
44 #include "Composer/Submission.h"
45 #include "Common/InvokeMethod.h"
46 #include "Common/Paths.h"
47 #include "Common/SettingsNames.h"
48 #include "Gui/ComposeWidget.h"
49 #include "Gui/FromAddressProxyModel.h"
50 #include "Gui/LineEdit.h"
51 #include "Gui/OverlayWidget.h"
52 #include "Gui/PasswordDialog.h"
53 #include "Gui/ProgressPopUp.h"
54 #include "Gui/Util.h"
55 #include "Gui/Window.h"
56 #include "Imap/Model/ImapAccess.h"
57 #include "Imap/Model/ItemRoles.h"
58 #include "Imap/Model/Model.h"
59 #include "Imap/Parser/MailAddress.h"
60 #include "Imap/Tasks/AppendTask.h"
61 #include "Imap/Tasks/GenUrlAuthTask.h"
62 #include "Imap/Tasks/UidSubmitTask.h"
63 #include "Plugins/AddressbookPlugin.h"
64 #include "Plugins/PluginManager.h"
65 #include "ShortcutHandler/ShortcutHandler.h"
66 #include "UiUtils/Color.h"
67 #include "UiUtils/IconLoader.h"
69 namespace
71 enum { OFFSET_OF_FIRST_ADDRESSEE = 1, MIN_MAX_VISIBLE_RECIPIENTS = 4 };
74 namespace Gui
77 static const QString trojita_opacityAnimation = QStringLiteral("trojita_opacityAnimation");
79 /** @short Keep track of whether the document has been updated since the last save */
80 class ComposerSaveState
82 public:
83 explicit ComposerSaveState(ComposeWidget* w)
84 : composer(w)
85 , messageUpdated(false)
86 , messageEverEdited(false)
90 void setMessageUpdated(bool updated)
92 if (updated == messageUpdated)
93 return;
94 messageUpdated = updated;
95 updateText();
99 void setMessageEverEdited(bool everEdited)
101 if (everEdited == messageEverEdited)
102 return;
103 messageEverEdited = everEdited;
104 updateText();
107 const bool everEdited() {return messageEverEdited;}
108 const bool updated() {return messageUpdated;}
109 private:
110 ComposeWidget* composer;
111 /** @short Has it been updated since the last time we auto-saved it? */
112 bool messageUpdated;
113 /** @short Was this message ever editted by human?
115 We have to track both of these. Simply changing the sender (and hence the signature) without any text being written
116 shall not trigger automatic saving, but on the other hand changing the sender after something was already written
117 is an important change.
119 bool messageEverEdited;
120 void updateText()
122 composer->cancelButton->setText((messageUpdated || messageEverEdited) ? QWidget::tr("Cancel...") : QWidget::tr("Cancel"));
126 /** @short Ignore dirtying events while we're preparing the widget's contents
128 Under the normal course of operation, there's plenty of events (user typing some text, etc) which lead to the composer widget
129 "remembering" that the human being has made some changes, and that these changes are probably worth a prompt for saving them
130 upon a close.
132 This guard object makes sure (via RAII) that these dirtifying events are ignored during its lifetime.
134 class InhibitComposerDirtying
136 public:
137 explicit InhibitComposerDirtying(ComposeWidget *w): w(w), wasEverEdited(w->m_saveState->everEdited()), wasEverUpdated(w->m_saveState->updated()) {}
138 ~InhibitComposerDirtying()
140 w->m_saveState->setMessageEverEdited(wasEverEdited);
141 w->m_saveState->setMessageUpdated(wasEverUpdated);
143 private:
144 ComposeWidget *w;
145 bool wasEverEdited, wasEverUpdated;
148 ComposeWidget::ComposeWidget(MainWindow *mainWindow, MSA::MSAFactory *msaFactory) :
149 QWidget(0, Qt::Window),
150 ui(new Ui::ComposeWidget),
151 m_maxVisibleRecipients(MIN_MAX_VISIBLE_RECIPIENTS),
152 m_sentMail(false),
153 m_explicitDraft(false),
154 m_appendUidReceived(false), m_appendUidValidity(0), m_appendUid(0), m_genUrlAuthReceived(false),
155 m_mainWindow(mainWindow),
156 m_settings(mainWindow->settings()),
157 m_submission(nullptr),
158 m_completionPopup(nullptr),
159 m_completionReceiver(nullptr)
161 setAttribute(Qt::WA_DeleteOnClose, true);
163 QIcon winIcon;
164 winIcon.addFile(QStringLiteral(":/icons/trojita-edit-big.svg"), QSize(128, 128));
165 winIcon.addFile(QStringLiteral(":/icons/trojita-edit-small.svg"), QSize(22, 22));
166 setWindowIcon(winIcon);
168 Q_ASSERT(m_mainWindow);
169 QString profileName = QString::fromUtf8(qgetenv("TROJITA_PROFILE"));
170 QString accountId = profileName.isEmpty() ? QStringLiteral("account-0") : profileName;
171 m_submission = new Composer::Submission(this, m_mainWindow->imapModel(), msaFactory, accountId);
172 connect(m_submission, &Composer::Submission::succeeded, this, &ComposeWidget::sent);
173 connect(m_submission, &Composer::Submission::failed, this, &ComposeWidget::gotError);
174 connect(m_submission, &Composer::Submission::passwordRequested, this, &ComposeWidget::passwordRequested, Qt::QueuedConnection);
175 m_submission->composer()->setReportTrojitaVersions(m_settings->value(Common::SettingsNames::interopRevealVersions, true).toBool());
177 ui->setupUi(this);
178 ui->attachmentsView->setComposer(m_submission->composer());
179 sendButton = ui->buttonBox->addButton(tr("Send"), QDialogButtonBox::AcceptRole);
180 sendButton->setIcon(UiUtils::loadIcon(QStringLiteral("mail-send")));
181 connect(sendButton, &QAbstractButton::clicked, this, &ComposeWidget::send);
182 cancelButton = ui->buttonBox->addButton(QDialogButtonBox::Cancel);
183 cancelButton->setIcon(UiUtils::loadIcon(QStringLiteral("dialog-cancel")));
184 connect(cancelButton, &QAbstractButton::clicked, this, &QWidget::close);
185 connect(ui->attachButton, &QAbstractButton::clicked, this, &ComposeWidget::slotAskForFileAttachment);
187 m_saveState = std::unique_ptr<ComposerSaveState>(new ComposerSaveState(this));
189 m_completionPopup = new QMenu(this);
190 m_completionPopup->installEventFilter(this);
191 connect(m_completionPopup, &QMenu::triggered, this, &ComposeWidget::completeRecipient);
193 // TODO: make this configurable?
194 m_completionCount = 8;
196 m_recipientListUpdateTimer = new QTimer(this);
197 m_recipientListUpdateTimer->setSingleShot(true);
198 m_recipientListUpdateTimer->setInterval(250);
199 connect(m_recipientListUpdateTimer, &QTimer::timeout, this, &ComposeWidget::updateRecipientList);
201 connect(ui->verticalSplitter, &QSplitter::splitterMoved, this, &ComposeWidget::calculateMaxVisibleRecipients);
202 calculateMaxVisibleRecipients();
204 connect(ui->recipientSlider, &QAbstractSlider::valueChanged, this, &ComposeWidget::scrollRecipients);
205 connect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
206 ui->recipientSlider->setMinimum(0);
207 ui->recipientSlider->setMaximum(0);
208 ui->recipientSlider->setVisible(false);
209 ui->envelopeWidget->installEventFilter(this);
211 m_markButton = new QToolButton(ui->buttonBox);
212 m_markButton->setPopupMode(QToolButton::MenuButtonPopup);
213 m_markButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
214 m_markAsReply = new QActionGroup(m_markButton);
215 m_markAsReply->setExclusive(true);
216 auto *asReplyMenu = new QMenu(m_markButton);
217 m_markButton->setMenu(asReplyMenu);
218 m_actionStandalone = asReplyMenu->addAction(UiUtils::loadIcon(QStringLiteral("format-justify-fill")), tr("New Thread"));
219 m_actionStandalone->setActionGroup(m_markAsReply);
220 m_actionStandalone->setCheckable(true);
221 m_actionStandalone->setToolTip(tr("This mail will be sent as a standalone message.<hr/>Change to preserve the reply hierarchy."));
222 m_actionInReplyTo = asReplyMenu->addAction(UiUtils::loadIcon(QStringLiteral("format-justify-right")), tr("Threaded"));
223 m_actionInReplyTo->setActionGroup(m_markAsReply);
224 m_actionInReplyTo->setCheckable(true);
226 // This is a "quick shortcut action". It shows the UI bits of the current option, but when the user clicks it,
227 // the *other* action is triggered.
228 m_actionToggleMarking = new QAction(m_markButton);
229 connect(m_actionToggleMarking, &QAction::triggered, this, &ComposeWidget::toggleReplyMarking);
230 m_markButton->setDefaultAction(m_actionToggleMarking);
232 // Unfortunately, there's no signal for toggled(QAction*), so we'll have to call QAction::trigger() to have this working
233 connect(m_markAsReply, &QActionGroup::triggered, this, &ComposeWidget::updateReplyMarkingAction);
234 m_actionStandalone->trigger();
236 m_replyModeButton = new QToolButton(ui->buttonBox);
237 m_replyModeButton->setPopupMode(QToolButton::InstantPopup);
238 m_replyModeButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
240 QMenu *replyModeMenu = new QMenu(m_replyModeButton);
241 m_replyModeButton->setMenu(replyModeMenu);
243 m_replyModeActions = new QActionGroup(m_replyModeButton);
244 m_replyModeActions->setExclusive(true);
246 m_actionHandPickedRecipients = new QAction(UiUtils::loadIcon(QStringLiteral("document-edit")) ,QStringLiteral("Hand Picked Recipients"), this);
247 replyModeMenu->addAction(m_actionHandPickedRecipients);
248 m_actionHandPickedRecipients->setActionGroup(m_replyModeActions);
249 m_actionHandPickedRecipients->setCheckable(true);
251 replyModeMenu->addSeparator();
253 QAction *placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_private"));
254 m_actionReplyModePrivate = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
255 m_actionReplyModePrivate->setActionGroup(m_replyModeActions);
256 m_actionReplyModePrivate->setCheckable(true);
258 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_all_but_me"));
259 m_actionReplyModeAllButMe = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
260 m_actionReplyModeAllButMe->setActionGroup(m_replyModeActions);
261 m_actionReplyModeAllButMe->setCheckable(true);
263 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_all"));
264 m_actionReplyModeAll = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
265 m_actionReplyModeAll->setActionGroup(m_replyModeActions);
266 m_actionReplyModeAll->setCheckable(true);
268 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_list"));
269 m_actionReplyModeList = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
270 m_actionReplyModeList->setActionGroup(m_replyModeActions);
271 m_actionReplyModeList->setCheckable(true);
273 connect(m_replyModeActions, &QActionGroup::triggered, this, &ComposeWidget::updateReplyMode);
275 // We want to have the button aligned to the left; the only "portable" way of this is the ResetRole
276 // (thanks to TL for mentioning this, and for the Qt's doc for providing pretty pictures on different platforms)
277 ui->buttonBox->addButton(m_markButton, QDialogButtonBox::ResetRole);
278 // Using ResetRole for reasons same as with m_markButton. We want this button to be second from the left.
279 ui->buttonBox->addButton(m_replyModeButton, QDialogButtonBox::ResetRole);
281 m_markButton->hide();
282 m_replyModeButton->hide();
284 connect(ui->mailText, &ComposerTextEdit::urlsAdded, this, &ComposeWidget::slotAttachFiles);
285 connect(ui->mailText, &ComposerTextEdit::sendRequest, this, &ComposeWidget::send);
286 connect(ui->mailText, &QTextEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
287 connect(ui->subject, &QLineEdit::textChanged, this, &ComposeWidget::updateWindowTitle);
288 connect(ui->subject, &QLineEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
289 updateWindowTitle();
291 FromAddressProxyModel *proxy = new FromAddressProxyModel(this);
292 proxy->setSourceModel(m_mainWindow->senderIdentitiesModel());
293 ui->sender->setModel(proxy);
295 connect(ui->sender, static_cast<void (QComboBox::*)(const int)>(&QComboBox::currentIndexChanged), this, &ComposeWidget::slotUpdateSignature);
296 connect(ui->sender, &QComboBox::editTextChanged, this, &ComposeWidget::setMessageUpdated);
297 connect(ui->sender->lineEdit(), &QLineEdit::textChanged, this, &ComposeWidget::slotCheckAddressOfSender);
299 QTimer *autoSaveTimer = new QTimer(this);
300 connect(autoSaveTimer, &QTimer::timeout, this, &ComposeWidget::autoSaveDraft);
301 autoSaveTimer->start(30*1000);
303 // these are for the automatically saved drafts, i.e. no i18n for the dir name
304 m_autoSavePath = QString(Common::writablePath(Common::LOCATION_CACHE) + QLatin1String("Drafts/"));
305 QDir().mkpath(m_autoSavePath);
307 m_autoSavePath += QString::number(QDateTime::currentMSecsSinceEpoch()) + QLatin1String(".draft");
309 // Add a blank recipient row to start with
310 addRecipient(m_recipients.count(), Composer::ADDRESS_TO, QString());
311 ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE, QFormLayout::FieldRole)->widget()->setFocus();
313 slotUpdateSignature();
315 // default size
316 int sz = ui->mailText->idealWidth();
317 ui->mailText->setMinimumSize(sz, 1000*sz/1618); // golden mean editor
318 adjustSize();
319 ui->mailText->setMinimumSize(0, 0);
320 resize(size().boundedTo(qApp->desktop()->availableGeometry().size()));
323 ComposeWidget::~ComposeWidget()
325 delete ui;
328 /** @short Throw a warning at an attempt to create a Compose Widget while the MSA is not configured */
329 ComposeWidget *ComposeWidget::warnIfMsaNotConfigured(ComposeWidget *widget, MainWindow *mainWindow)
331 if (!widget)
332 QMessageBox::critical(mainWindow, tr("Error"), tr("Please set appropriate settings for outgoing messages."));
333 return widget;
336 /** @short Find a nice position near the mid of the main window, try to not fully occlude another sibling */
337 void ComposeWidget::placeOnMainWindow()
339 QRect area = m_mainWindow->geometry();
340 QRect origin(0, 0, width(), height());
341 origin.moveTo(area.x() + (area.width() - width()) / 2,
342 area.y() + (area.height() - height()) / 2);
343 QRect target = origin;
345 QWidgetList siblings;
346 foreach(const QWidget *w, QApplication::topLevelWidgets()) {
347 if (w == this)
348 continue; // I'm not a sibling of myself
349 if (!qobject_cast<const ComposeWidget*>(w))
350 continue; // random other stuff
351 siblings << const_cast<QWidget*>(w);
353 int dx = 20, dy = 20;
354 int i = 0;
355 // look for a position where the window would not fully cover another composer
356 // (we don't want to mass open 10 composers stashing each other)
357 // if such composer blocks our desired geometry, the new desired geometry is
358 // tested at positions shifted by 20px circling around the original one.
359 // if we're already more than 100px off the center (what implies the user
360 // has > 20 composers open ...) we give up to not shift the window
361 // too far away, maybe even off-screen.
362 // Notice that it may still happen that some composers *together* stash a 3rd one
363 while (i < siblings.count()) {
364 if (target.contains(siblings.at(i)->geometry())) {
365 target = origin.translated(dx, dy);
366 if (dx < 0 && dy < 0) {
367 dx = dy = -dx + 20;
368 if (dx >= 120) // give up
369 break;
370 } else if (dx < 0 || dy < 0) {
371 dx = -dx;
372 if (dy > 0)
373 dy = -dy;
374 } else {
375 dx = -dx;
377 i = 0;
378 } else {
379 ++i;
382 setGeometry(target);
385 /** @short Create a blank composer window */
386 ComposeWidget *ComposeWidget::createBlank(MainWindow *mainWindow)
388 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
389 if (!msaFactory)
390 return 0;
392 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
393 w->placeOnMainWindow();
394 w->show();
395 return w;
398 /** @short Load a draft in composer window */
399 ComposeWidget *ComposeWidget::createDraft(MainWindow *mainWindow, const QString &path)
401 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
402 if (!msaFactory)
403 return 0;
405 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
406 w->loadDraft(path);
407 w->placeOnMainWindow();
408 w->show();
409 return w;
412 /** @short Create a composer window with data from a URL */
413 ComposeWidget *ComposeWidget::createFromUrl(MainWindow *mainWindow, const QUrl &url)
415 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
416 if (!msaFactory)
417 return 0;
419 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
420 InhibitComposerDirtying inhibitor(w);
421 QString subject;
422 QString body;
423 QList<QPair<Composer::RecipientKind,QString> > recipients;
424 QList<QByteArray> inReplyTo;
425 QList<QByteArray> references;
426 const QUrlQuery q(url);
428 if (!q.queryItemValue(QStringLiteral("X-Trojita-DisplayName")).isEmpty()) {
429 // There should be only single email address created by Imap::Message::MailAddress::asUrl()
430 Imap::Message::MailAddress addr;
431 if (Imap::Message::MailAddress::fromUrl(addr, url, QStringLiteral("mailto")))
432 recipients << qMakePair(Composer::ADDRESS_TO, addr.asPrettyString());
433 } else {
434 // This should be real RFC 6068 mailto:
435 Composer::parseRFC6068Mailto(url, subject, body, recipients, inReplyTo, references);
438 // NOTE: we need inReplyTo and references parameters without angle brackets, so remove them
439 for (int i = 0; i < inReplyTo.size(); ++i) {
440 if (inReplyTo[i].startsWith('<') && inReplyTo[i].endsWith('>')) {
441 inReplyTo[i] = inReplyTo[i].mid(1, inReplyTo[i].size()-2);
444 for (int i = 0; i < references.size(); ++i) {
445 if (references[i].startsWith('<') && references[i].endsWith('>')) {
446 references[i] = references[i].mid(1, references[i].size()-2);
450 w->setResponseData(recipients, subject, body, inReplyTo, references, QModelIndex());
451 if (!inReplyTo.isEmpty() || !references.isEmpty()) {
452 // We don't need to expose any UI here, but we want the in-reply-to and references information to be carried with this message
453 w->m_actionInReplyTo->setChecked(true);
455 w->placeOnMainWindow();
456 w->show();
457 return w;
460 /** @short Create a composer window for a reply */
461 ComposeWidget *ComposeWidget::createReply(MainWindow *mainWindow, const Composer::ReplyMode &mode, const QModelIndex &replyingToMessage,
462 const QList<QPair<Composer::RecipientKind, QString> > &recipients, const QString &subject,
463 const QString &body, const QList<QByteArray> &inReplyTo, const QList<QByteArray> &references)
465 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
466 if (!msaFactory)
467 return 0;
469 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
470 InhibitComposerDirtying inhibitor(w);
471 w->setResponseData(recipients, subject, body, inReplyTo, references, replyingToMessage);
472 bool ok = w->setReplyMode(mode);
473 if (!ok) {
474 QString err;
475 switch (mode) {
476 case Composer::REPLY_ALL:
477 case Composer::REPLY_ALL_BUT_ME:
478 // do nothing
479 break;
480 case Composer::REPLY_LIST:
481 err = tr("It doesn't look like this is a message to the mailing list. Please fill in the recipients manually.");
482 break;
483 case Composer::REPLY_PRIVATE:
484 err = trUtf8("Trojitá was unable to safely determine the real e-mail address of the author of the message. "
485 "You might want to use the \"Reply All\" function and trim the list of addresses manually.");
486 break;
488 if (!err.isEmpty()) {
489 Gui::Util::messageBoxWarning(w, tr("Cannot Determine Recipients"), err);
492 w->placeOnMainWindow();
493 w->show();
494 return w;
497 /** @short Create a composer window for a mail-forward action */
498 ComposeWidget *ComposeWidget::createForward(MainWindow *mainWindow, const Composer::ForwardMode mode, const QModelIndex &forwardingMessage,
499 const QString &subject, const QList<QByteArray> &inReplyTo, const QList<QByteArray> &references)
501 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
502 if (!msaFactory)
503 return 0;
505 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
506 InhibitComposerDirtying inhibitor(w);
507 w->setResponseData(QList<QPair<Composer::RecipientKind, QString>>(), subject, QString(), inReplyTo, references, QModelIndex());
508 // We don't need to expose any UI here, but we want the in-reply-to and references information to be carried with this message
509 w->m_actionInReplyTo->setChecked(true);
511 // Prepare the message to be forwarded and add it to the attachments view
512 w->m_submission->composer()->prepareForwarding(forwardingMessage, mode);
514 w->placeOnMainWindow();
515 w->show();
516 return w;
519 void ComposeWidget::updateReplyMode()
521 bool replyModeSet = false;
522 if (m_actionReplyModePrivate->isChecked()) {
523 replyModeSet = setReplyMode(Composer::REPLY_PRIVATE);
524 } else if (m_actionReplyModeAllButMe->isChecked()) {
525 replyModeSet = setReplyMode(Composer::REPLY_ALL_BUT_ME);
526 } else if (m_actionReplyModeAll->isChecked()) {
527 replyModeSet = setReplyMode(Composer::REPLY_ALL);
528 } else if (m_actionReplyModeList->isChecked()) {
529 replyModeSet = setReplyMode(Composer::REPLY_LIST);
532 if (!replyModeSet) {
533 // This is for now by design going in one direction only, from enabled to disabled.
534 // The index to the message cannot become valid again, and simply marking the buttons as disabled does the trick quite neatly.
535 m_replyModeButton->setEnabled(m_actionHandPickedRecipients->isChecked());
536 markReplyModeHandpicked();
540 void ComposeWidget::markReplyModeHandpicked()
542 m_actionHandPickedRecipients->setChecked(true);
543 m_replyModeButton->setText(m_actionHandPickedRecipients->text());
544 m_replyModeButton->setIcon(m_actionHandPickedRecipients->icon());
547 void ComposeWidget::passwordRequested(const QString &user, const QString &host)
549 if (m_settings->value(Common::SettingsNames::smtpAuthReuseImapCredsKey, false).toBool()) {
550 auto password = qobject_cast<const Imap::Mailbox::Model*>(m_mainWindow->imapAccess()->imapModel())->imapPassword();
551 if (password.isNull()) {
552 // This can happen for example when we've always been offline since the last profile change,
553 // and the IMAP password is therefore not already cached in the IMAP model.
555 // FIXME: it would be nice to "just" call out to MainWindow::authenticationRequested() in that case,
556 // but there's no async callback when the password is available. Just some food for thought when
557 // that part gets refactored :), eventually...
558 askPassword(user, host);
559 } else {
560 m_submission->setPassword(password);
562 return;
565 Plugins::PasswordPlugin *password = m_mainWindow->pluginManager()->password();
566 if (!password) {
567 askPassword(user, host);
568 return;
571 // FIXME: use another account-id at some point in future
572 // we are now using the profile to avoid overwriting passwords of
573 // other profiles in secure storage
574 // 'account-0' is the hardcoded value when not using a profile
575 Plugins::PasswordJob *job = password->requestPassword(m_submission->accountId(), QStringLiteral("smtp"));
576 if (!job) {
577 askPassword(user, host);
578 return;
581 connect(job, &Plugins::PasswordJob::passwordAvailable, m_submission, &Composer::Submission::setPassword);
582 connect(job, &Plugins::PasswordJob::error, this, &ComposeWidget::passwordError);
584 job->setAutoDelete(true);
585 job->setProperty("user", user);
586 job->setProperty("host", host);
587 job->start();
590 void ComposeWidget::passwordError()
592 Plugins::PasswordJob *job = static_cast<Plugins::PasswordJob *>(sender());
593 const QString &user = job->property("user").toString();
594 const QString &host = job->property("host").toString();
595 askPassword(user, host);
598 void ComposeWidget::askPassword(const QString &user, const QString &host)
600 auto w = Gui::PasswordDialog::getPassword(this, tr("Authentication Required"),
601 tr("<p>Please provide SMTP password for user <b>%1</b> on <b>%2</b>:</p>").arg(
602 user.toHtmlEscaped(),
603 host.toHtmlEscaped()));
604 connect(w, &Gui::PasswordDialog::gotPassword, m_submission, &Composer::Submission::setPassword);
605 connect(w, &Gui::PasswordDialog::rejected, m_submission, &Composer::Submission::cancelPassword);
608 void ComposeWidget::changeEvent(QEvent *e)
610 QWidget::changeEvent(e);
611 switch (e->type()) {
612 case QEvent::LanguageChange:
613 ui->retranslateUi(this);
614 break;
615 default:
616 break;
621 * We capture the close event and check whether there's something to save
622 * (not sent, not up-to-date or persistent autostore)
623 * The offer the user to store or omit the message or not close at all
626 void ComposeWidget::closeEvent(QCloseEvent *ce)
628 const bool noSaveRequired = m_sentMail || !m_saveState->everEdited() ||
629 (m_explicitDraft && !m_saveState->updated()); // autosave to permanent draft and no update
630 if (!noSaveRequired) { // save is required
631 QMessageBox msgBox(this);
632 msgBox.setWindowModality(Qt::WindowModal);
633 msgBox.setWindowTitle(tr("Save Draft?"));
634 QString message(tr("The mail has not been sent.<br>Do you want to save the draft?"));
635 if (ui->attachmentsView->model()->rowCount() > 0)
636 message += tr("<br><span style=\"color:red\">Warning: Attachments are <b>not</b> saved with the draft!</span>");
637 msgBox.setText(message);
638 msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
639 msgBox.setDefaultButton(QMessageBox::Save);
640 int ret = msgBox.exec();
641 if (ret == QMessageBox::Save) {
642 if (m_explicitDraft) { // editing a present draft - override it
643 saveDraft(m_autoSavePath);
644 } else {
645 // Explicitly stored drafts should be saved in a location with proper i18n support, so let's make sure both main
646 // window and this code uses the same tr() calls
647 QString path(Common::writablePath(Common::LOCATION_DATA) + Gui::MainWindow::tr("Drafts"));
648 QDir().mkpath(path);
649 QString filename = ui->subject->text();
650 if (filename.isEmpty()) {
651 filename = QDateTime::currentDateTime().toString(Qt::ISODate);
653 // Some characters are best avoided in file names. This is probably not a definitive list, but the hope is that
654 // it's going to be more readable than an unformatted hash or similar stuff. The list of characters was taken
655 // from http://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words .
656 filename.replace(QRegExp(QLatin1String("[/\\\\:\"|<>*?]")), QStringLiteral("_"));
657 path = QFileDialog::getSaveFileName(this, tr("Save as"), path + QLatin1Char('/') + filename + QLatin1String(".draft"),
658 tr("Drafts") + QLatin1String(" (*.draft)"));
659 if (path.isEmpty()) { // cancelled save
660 ret = QMessageBox::Cancel;
661 } else {
662 m_explicitDraft = true;
663 saveDraft(path);
664 if (path != m_autoSavePath) // we can remove the temp save
665 QFile::remove(m_autoSavePath);
669 if (ret == QMessageBox::Cancel) {
670 ce->ignore(); // don't close the window
671 return;
674 if (m_sentMail || !m_explicitDraft) // is the mail has been sent or the user does not want to store it
675 QFile::remove(m_autoSavePath); // get rid of draft
676 ce->accept(); // ultimately close the window
681 bool ComposeWidget::buildMessageData()
683 QList<QPair<Composer::RecipientKind,Imap::Message::MailAddress> > recipients;
684 QString errorMessage;
685 if (!parseRecipients(recipients, errorMessage)) {
686 gotError(tr("Cannot parse recipients:\n%1").arg(errorMessage));
687 return false;
689 if (recipients.isEmpty()) {
690 gotError(tr("You haven't entered any recipients"));
691 return false;
693 m_submission->composer()->setRecipients(recipients);
695 Imap::Message::MailAddress fromAddress;
696 if (!Imap::Message::MailAddress::fromPrettyString(fromAddress, ui->sender->currentText())) {
697 gotError(tr("The From: address does not look like a valid one"));
698 return false;
700 if (ui->subject->text().isEmpty()) {
701 gotError(tr("You haven't entered any subject. Cannot send such a mail, sorry."));
702 ui->subject->setFocus();
703 return false;
705 m_submission->composer()->setFrom(fromAddress);
707 m_submission->composer()->setTimestamp(QDateTime::currentDateTime());
708 m_submission->composer()->setSubject(ui->subject->text());
710 QAbstractProxyModel *proxy = qobject_cast<QAbstractProxyModel*>(ui->sender->model());
711 Q_ASSERT(proxy);
713 if (ui->sender->findText(ui->sender->currentText()) != -1) {
714 QModelIndex proxyIndex = ui->sender->model()->index(ui->sender->currentIndex(), 0, ui->sender->rootModelIndex());
715 Q_ASSERT(proxyIndex.isValid());
716 m_submission->composer()->setOrganization(
717 proxy->mapToSource(proxyIndex).sibling(proxyIndex.row(), Composer::SenderIdentitiesModel::COLUMN_ORGANIZATION)
718 .data().toString());
720 m_submission->composer()->setText(ui->mailText->toPlainText());
722 if (m_actionInReplyTo->isChecked()) {
723 m_submission->composer()->setInReplyTo(m_inReplyTo);
724 m_submission->composer()->setReferences(m_references);
725 m_submission->composer()->setReplyingToMessage(m_replyingToMessage);
726 } else {
727 m_submission->composer()->setInReplyTo(QList<QByteArray>());
728 m_submission->composer()->setReferences(QList<QByteArray>());
729 m_submission->composer()->setReplyingToMessage(QModelIndex());
732 return m_submission->composer()->isReadyForSerialization();
735 void ComposeWidget::send()
737 // Well, Trojita is of course rock solid and will never ever crash :), but experience has shown that every now and then,
738 // there is a subtle issue $somewhere. This means that it's probably a good idea to save the draft explicitly -- better
739 // than losing some work. It's cheap anyway.
740 saveDraft(m_autoSavePath);
742 if (!buildMessageData())
743 return;
745 const bool reuseImapCreds = m_settings->value(Common::SettingsNames::smtpAuthReuseImapCredsKey, false).toBool();
746 m_submission->setImapOptions(m_settings->value(Common::SettingsNames::composerSaveToImapKey, true).toBool(),
747 m_settings->value(Common::SettingsNames::composerImapSentKey, QStringLiteral("Sent")).toString(),
748 m_settings->value(Common::SettingsNames::imapHostKey).toString(),
749 m_settings->value(Common::SettingsNames::imapUserKey).toString(),
750 m_settings->value(Common::SettingsNames::msaMethodKey).toString() == Common::SettingsNames::methodImapSendmail);
751 m_submission->setSmtpOptions(m_settings->value(Common::SettingsNames::smtpUseBurlKey, false).toBool(),
752 reuseImapCreds ?
753 m_mainWindow->imapAccess()->username() :
754 m_settings->value(Common::SettingsNames::smtpUserKey).toString());
756 ProgressPopUp *progress = new ProgressPopUp();
757 OverlayWidget *overlay = new OverlayWidget(progress, this);
758 overlay->show();
759 setUiWidgetsEnabled(false);
761 connect(m_submission, &Composer::Submission::progressMin, progress, &ProgressPopUp::setMinimum);
762 connect(m_submission, &Composer::Submission::progressMax, progress, &ProgressPopUp::setMaximum);
763 connect(m_submission, &Composer::Submission::progress, progress, &ProgressPopUp::setValue);
764 connect(m_submission, &Composer::Submission::updateStatusMessage, progress, &ProgressPopUp::setLabelText);
765 connect(m_submission, &Composer::Submission::succeeded, overlay, &QObject::deleteLater);
766 connect(m_submission, &Composer::Submission::failed, overlay, &QObject::deleteLater);
768 m_submission->send();
771 void ComposeWidget::setUiWidgetsEnabled(const bool enabled)
773 ui->verticalSplitter->setEnabled(enabled);
774 ui->buttonBox->setEnabled(enabled);
777 /** @short Set private data members to get pre-filled by available parameters
779 The semantics of the @arg inReplyTo and @arg references are the same as described for the Composer::MessageComposer,
780 i.e. the data are not supposed to contain the angle bracket. If the @arg replyingToMessage is present, it will be used
781 as an index to a message which will get marked as replied to. This is needed because IMAP doesn't really support site-wide
782 search by a Message-Id (and it cannot possibly support it in general, either), and because Trojita's lazy loading and lack
783 of cross-session persistent indexes means that "mark as replied" and "extract message-id from" are effectively two separate
784 operations.
786 void ComposeWidget::setResponseData(const QList<QPair<Composer::RecipientKind, QString> > &recipients,
787 const QString &subject, const QString &body, const QList<QByteArray> &inReplyTo,
788 const QList<QByteArray> &references, const QModelIndex &replyingToMessage)
790 InhibitComposerDirtying inhibitor(this);
791 for (int i = 0; i < recipients.size(); ++i) {
792 addRecipient(i, recipients.at(i).first, recipients.at(i).second);
794 updateRecipientList();
795 ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE, QFormLayout::FieldRole)->widget()->setFocus();
796 ui->subject->setText(subject);
797 ui->mailText->setText(body);
798 m_inReplyTo = inReplyTo;
800 // Trim the References header as per RFC 5537
801 QList<QByteArray> trimmedReferences = references;
802 int referencesSize = QByteArray("References: ").size();
803 const int lineOverhead = 3; // one for the " " prefix, two for the \r\n suffix
804 Q_FOREACH(const QByteArray &item, references)
805 referencesSize += item.size() + lineOverhead;
806 // The magic numbers are from RFC 5537
807 while (referencesSize >= 998 && trimmedReferences.size() > 3) {
808 referencesSize -= trimmedReferences.takeAt(1).size() + lineOverhead;
810 m_references = trimmedReferences;
811 m_replyingToMessage = replyingToMessage;
812 if (m_replyingToMessage.isValid()) {
813 m_markButton->show();
814 m_replyModeButton->show();
815 // Got to use trigger() so that the default action of the QToolButton is updated
816 m_actionInReplyTo->setToolTip(tr("This mail will be marked as a response<hr/>%1").arg(
817 m_replyingToMessage.data(Imap::Mailbox::RoleMessageSubject).toString().toHtmlEscaped()
819 m_actionInReplyTo->trigger();
821 // Enable only those Reply Modes that are applicable to the message to be replied
822 Composer::RecipientList dummy;
823 m_actionReplyModePrivate->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_PRIVATE,
824 m_mainWindow->senderIdentitiesModel(),
825 m_replyingToMessage, dummy));
826 m_actionReplyModeAllButMe->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_ALL_BUT_ME,
827 m_mainWindow->senderIdentitiesModel(),
828 m_replyingToMessage, dummy));
829 m_actionReplyModeAll->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_ALL,
830 m_mainWindow->senderIdentitiesModel(),
831 m_replyingToMessage, dummy));
832 m_actionReplyModeList->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_LIST,
833 m_mainWindow->senderIdentitiesModel(),
834 m_replyingToMessage, dummy));
835 } else {
836 m_markButton->hide();
837 m_replyModeButton->hide();
838 m_actionInReplyTo->setToolTip(QString());
839 m_actionStandalone->trigger();
842 int row = -1;
843 bool ok = Composer::Util::chooseSenderIdentityForReply(m_mainWindow->senderIdentitiesModel(), replyingToMessage, row);
844 if (ok) {
845 Q_ASSERT(row >= 0 && row < m_mainWindow->senderIdentitiesModel()->rowCount());
846 ui->sender->setCurrentIndex(row);
849 slotUpdateSignature();
852 /** @short Find out what type of recipient to use for the last row */
853 Composer::RecipientKind ComposeWidget::recipientKindForNextRow(const Composer::RecipientKind kind)
855 using namespace Imap::Mailbox;
856 switch (kind) {
857 case Composer::ADDRESS_TO:
858 // Heuristic: if the last one is "to", chances are that the next one shall not be "to" as well.
859 // Cc is reasonable here.
860 return Composer::ADDRESS_CC;
861 case Composer::ADDRESS_CC:
862 case Composer::ADDRESS_BCC:
863 // In any other case, it is probably better to just reuse the type of the last row
864 return kind;
865 case Composer::ADDRESS_FROM:
866 case Composer::ADDRESS_SENDER:
867 case Composer::ADDRESS_REPLY_TO:
868 // shall never be used here
869 Q_ASSERT(false);
870 return kind;
872 Q_ASSERT(false);
873 return Composer::ADDRESS_TO;
876 //BEGIN QFormLayout workarounds
878 /** First issue: QFormLayout messes up rows by never removing them
879 * ----------------------------------------------------------------
880 * As a result insertRow(int pos, .) does not pick the expected row, but usually minor
881 * (if you ever removed all items of a row in this layout)
883 * Solution: we count all rows non empty rows and when we have enough, return the row suitable for
884 * QFormLayout (which is usually behind the requested one)
886 static int actualRow(QFormLayout *form, int row)
888 for (int i = 0, c = 0; i < form->rowCount(); ++i) {
889 if (c == row) {
890 return i;
892 if (form->itemAt(i, QFormLayout::LabelRole) || form->itemAt(i, QFormLayout::FieldRole) ||
893 form->itemAt(i, QFormLayout::SpanningRole))
894 ++c;
896 return form->rowCount(); // append
899 /** Second (related) issue: QFormLayout messes the tab order
900 * ----------------------------------------------------------
901 * "Inserted" rows just get appended to the present ones and by this to the tab focus order
902 * It's therefore necessary to fix this forcing setTabOrder()
904 * Approach: traverse all rows until we have the widget that shall be inserted in tab order and
905 * return it's predecessor
908 static QWidget* formPredecessor(QFormLayout *form, QWidget *w)
910 QWidget *pred = 0;
911 QWidget *runner = 0;
912 QLayoutItem *item = 0;
913 for (int i = 0; i < form->rowCount(); ++i) {
914 if ((item = form->itemAt(i, QFormLayout::LabelRole))) {
915 runner = item->widget();
916 if (runner == w)
917 return pred;
918 else if (runner)
919 pred = runner;
921 if ((item = form->itemAt(i, QFormLayout::FieldRole))) {
922 runner = item->widget();
923 if (runner == w)
924 return pred;
925 else if (runner)
926 pred = runner;
928 if ((item = form->itemAt(i, QFormLayout::SpanningRole))) {
929 runner = item->widget();
930 if (runner == w)
931 return pred;
932 else if (runner)
933 pred = runner;
936 return pred;
939 //END QFormLayout workarounds
941 void ComposeWidget::calculateMaxVisibleRecipients()
943 const int oldMaxVisibleRecipients = m_maxVisibleRecipients;
944 int spacing, bottom;
945 ui->envelopeLayout->getContentsMargins(&spacing, &spacing, &spacing, &bottom);
946 // we abuse the fact that there's always an addressee and that they all look the same
947 QRect itemRects[2];
948 for (int i = 0; i < 2; ++i) {
949 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::LabelRole)) {
950 itemRects[i] |= li->geometry();
952 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::FieldRole)) {
953 itemRects[i] |= li->geometry();
955 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::SpanningRole)) {
956 itemRects[i] |= li->geometry();
959 int itemHeight = itemRects[0].height();
960 spacing = qMax(0, itemRects[0].top() - itemRects[1].bottom() - 1); // QFormLayout::[vertical]spacing() is useless ...
961 int firstTop = itemRects[0].top();
962 const int subjectHeight = ui->subject->height();
963 const int height = ui->verticalSplitter->sizes().at(0) - // entire splitter area
964 firstTop - // offset of first recipient
965 (subjectHeight + spacing) - // for the subject
966 bottom - // layout bottom padding
967 2; // extra pixels padding to detect that the user wants to shrink
968 if (itemHeight + spacing == 0) {
969 m_maxVisibleRecipients = MIN_MAX_VISIBLE_RECIPIENTS;
970 } else {
971 m_maxVisibleRecipients = height / (itemHeight + spacing);
973 if (m_maxVisibleRecipients < MIN_MAX_VISIBLE_RECIPIENTS)
974 m_maxVisibleRecipients = MIN_MAX_VISIBLE_RECIPIENTS; // allow up to 4 recipients w/o need for a sliding
975 if (oldMaxVisibleRecipients != m_maxVisibleRecipients) {
976 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
977 int v = qRound(1.0f*(ui->recipientSlider->value()*m_maxVisibleRecipients)/oldMaxVisibleRecipients);
978 ui->recipientSlider->setMaximum(max);
979 ui->recipientSlider->setVisible(max > 0);
980 scrollRecipients(qMin(qMax(0, v), max));
984 void ComposeWidget::addRecipient(int position, Composer::RecipientKind kind, const QString &address)
986 QComboBox *combo = new QComboBox(this);
987 combo->addItem(tr("To"), Composer::ADDRESS_TO);
988 combo->addItem(tr("Cc"), Composer::ADDRESS_CC);
989 combo->addItem(tr("Bcc"), Composer::ADDRESS_BCC);
990 combo->setCurrentIndex(combo->findData(kind));
991 LineEdit *edit = new LineEdit(address, this);
992 slotCheckAddress(edit);
993 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::slotCheckAddressOfSender);
994 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
995 connect(edit, &QLineEdit::textEdited, this, &ComposeWidget::completeRecipients);
996 connect(edit, &QLineEdit::editingFinished, this, &ComposeWidget::collapseRecipients);
997 connect(edit, &QLineEdit::textChanged, m_recipientListUpdateTimer, static_cast<void (QTimer::*)()>(&QTimer::start));
998 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::markReplyModeHandpicked);
999 m_recipients.insert(position, Recipient(combo, edit));
1000 ui->envelopeWidget->setUpdatesEnabled(false);
1001 ui->envelopeLayout->insertRow(actualRow(ui->envelopeLayout, position + OFFSET_OF_FIRST_ADDRESSEE), combo, edit);
1002 setTabOrder(formPredecessor(ui->envelopeLayout, combo), combo);
1003 setTabOrder(combo, edit);
1004 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
1005 ui->recipientSlider->setMaximum(max);
1006 ui->recipientSlider->setVisible(max > 0);
1007 if (ui->recipientSlider->isVisible()) {
1008 const int v = ui->recipientSlider->value();
1009 int keepInSight = ++position;
1010 for (int i = 0; i < m_recipients.count(); ++i) {
1011 if (m_recipients.at(i).first->hasFocus() || m_recipients.at(i).second->hasFocus()) {
1012 keepInSight = i;
1013 break;
1016 if (qAbs(keepInSight - position) < m_maxVisibleRecipients)
1017 ui->recipientSlider->setValue(position*max/m_recipients.count());
1018 if (v == ui->recipientSlider->value()) // force scroll update
1019 scrollRecipients(v);
1021 ui->envelopeWidget->setUpdatesEnabled(true);
1024 void ComposeWidget::slotCheckAddressOfSender()
1026 QLineEdit *edit = qobject_cast<QLineEdit*>(sender());
1027 Q_ASSERT(edit);
1028 slotCheckAddress(edit);
1031 void ComposeWidget::slotCheckAddress(QLineEdit *edit)
1033 Imap::Message::MailAddress addr;
1034 if (edit->text().isEmpty() || Imap::Message::MailAddress::fromPrettyString(addr, edit->text())) {
1035 edit->setPalette(QPalette());
1036 } else {
1037 QPalette p;
1038 p.setColor(QPalette::Base, UiUtils::tintColor(p.color(QPalette::Base), QColor(0xff, 0, 0, 0x20)));
1039 edit->setPalette(p);
1043 void ComposeWidget::removeRecipient(int pos)
1045 // removing the widgets from the layout is important
1046 // a) not doing so leaks (minor)
1047 // b) deleteLater() crosses the evenchain and so our actualRow function would be tricked
1048 QWidget *formerFocus = QApplication::focusWidget();
1049 if (!formerFocus)
1050 formerFocus = m_lastFocusedRecipient;
1052 if (pos + 1 < m_recipients.count()) {
1053 if (m_recipients.at(pos).first == formerFocus) {
1054 m_recipients.at(pos + 1).first->setFocus();
1055 formerFocus = m_recipients.at(pos + 1).first;
1056 } else if (m_recipients.at(pos).second == formerFocus) {
1057 m_recipients.at(pos + 1).second->setFocus();
1058 formerFocus = m_recipients.at(pos + 1).second;
1060 } else if (m_recipients.at(pos).first == formerFocus || m_recipients.at(pos).second == formerFocus) {
1061 formerFocus = 0;
1064 ui->envelopeLayout->removeWidget(m_recipients.at(pos).first);
1065 ui->envelopeLayout->removeWidget(m_recipients.at(pos).second);
1066 m_recipients.at(pos).first->deleteLater();
1067 m_recipients.at(pos).second->deleteLater();
1068 m_recipients.removeAt(pos);
1069 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
1070 ui->recipientSlider->setMaximum(max);
1071 ui->recipientSlider->setVisible(max > 0);
1072 if (formerFocus) {
1073 // skip event loop, remove might be triggered by imminent focus loss
1074 CALL_LATER_NOARG(formerFocus, setFocus);
1078 static inline Composer::RecipientKind currentRecipient(const QComboBox *box)
1080 return Composer::RecipientKind(box->itemData(box->currentIndex()).toInt());
1083 void ComposeWidget::updateRecipientList()
1085 // we ensure there's always one empty available
1086 bool haveEmpty = false;
1087 for (int i = 0; i < m_recipients.count(); ++i) {
1088 if (m_recipients.at(i).second->text().isEmpty()) {
1089 if (haveEmpty) {
1090 removeRecipient(i);
1092 haveEmpty = true;
1095 if (!haveEmpty) {
1096 addRecipient(m_recipients.count(),
1097 m_recipients.isEmpty() ?
1098 Composer::ADDRESS_TO :
1099 recipientKindForNextRow(currentRecipient(m_recipients.last().first)),
1100 QString());
1104 void ComposeWidget::handleFocusChange()
1106 // got explicit focus on other widget - don't restore former focused recipient on scrolling
1107 m_lastFocusedRecipient = QApplication::focusWidget();
1109 if (m_lastFocusedRecipient)
1110 QTimer::singleShot(150, this, SLOT(scrollToFocus())); // give user chance to notice the focus change disposition
1113 void ComposeWidget::scrollToFocus()
1115 if (!ui->recipientSlider->isVisible())
1116 return;
1118 QWidget *focus = QApplication::focusWidget();
1119 if (focus == ui->envelopeWidget)
1120 focus = m_lastFocusedRecipient;
1121 if (!focus)
1122 return;
1124 // if this is the first or last visible recipient, show one more (to hint there's more and allow tab progression)
1125 for (int i = 0, pos = 0; i < m_recipients.count(); ++i) {
1126 if (m_recipients.at(i).first->isVisible())
1127 ++pos;
1128 if (focus == m_recipients.at(i).first || focus == m_recipients.at(i).second) {
1129 if (pos > 1 && pos < m_maxVisibleRecipients) // prev & next are in sight
1130 break;
1131 if (pos == 1)
1132 ui->recipientSlider->setValue(i - 1); // scroll to prev
1133 else
1134 ui->recipientSlider->setValue(i + 2 - m_maxVisibleRecipients); // scroll to next
1135 break;
1138 if (focus == m_lastFocusedRecipient)
1139 focus->setFocus(); // in case we scrolled to m_lastFocusedRecipient
1142 void ComposeWidget::fadeIn(QWidget *w)
1144 QGraphicsOpacityEffect *effect = new QGraphicsOpacityEffect(w);
1145 w->setGraphicsEffect(effect);
1146 QPropertyAnimation *animation = new QPropertyAnimation(effect, "opacity", w);
1147 connect(animation, &QAbstractAnimation::finished, this, &ComposeWidget::slotFadeFinished);
1148 animation->setObjectName(trojita_opacityAnimation);
1149 animation->setDuration(333);
1150 animation->setStartValue(0.0);
1151 animation->setEndValue(1.0);
1152 animation->start(QAbstractAnimation::DeleteWhenStopped);
1155 void ComposeWidget::slotFadeFinished()
1157 Q_ASSERT(sender());
1158 QWidget *animatedEffectWidget = qobject_cast<QWidget*>(sender()->parent());
1159 Q_ASSERT(animatedEffectWidget);
1160 animatedEffectWidget->setGraphicsEffect(0); // deletes old one
1163 void ComposeWidget::scrollRecipients(int value)
1165 // ignore focus changes caused by "scrolling"
1166 disconnect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
1168 QList<QWidget*> visibleWidgets;
1169 for (int i = 0; i < m_recipients.count(); ++i) {
1170 // remove all widgets from the form because of vspacing - causes spurious padding
1172 QWidget *toCC = m_recipients.at(i).first;
1173 QWidget *lineEdit = m_recipients.at(i).second;
1174 if (!m_lastFocusedRecipient) { // apply only _once_
1175 if (toCC->hasFocus())
1176 m_lastFocusedRecipient = toCC;
1177 else if (lineEdit->hasFocus())
1178 m_lastFocusedRecipient = lineEdit;
1180 if (toCC->isVisible())
1181 visibleWidgets << toCC;
1182 if (lineEdit->isVisible())
1183 visibleWidgets << lineEdit;
1184 ui->envelopeLayout->removeWidget(toCC);
1185 ui->envelopeLayout->removeWidget(lineEdit);
1186 toCC->hide();
1187 lineEdit->hide();
1190 const int begin = qMin(m_recipients.count(), value);
1191 const int end = qMin(m_recipients.count(), value + m_maxVisibleRecipients);
1192 for (int i = begin, j = 0; i < end; ++i, ++j) {
1193 const int pos = actualRow(ui->envelopeLayout, j + OFFSET_OF_FIRST_ADDRESSEE);
1194 QWidget *toCC = m_recipients.at(i).first;
1195 QWidget *lineEdit = m_recipients.at(i).second;
1196 ui->envelopeLayout->insertRow(pos, toCC, lineEdit);
1197 if (!visibleWidgets.contains(toCC))
1198 fadeIn(toCC);
1199 visibleWidgets.removeOne(toCC);
1200 if (!visibleWidgets.contains(lineEdit))
1201 fadeIn(lineEdit);
1202 visibleWidgets.removeOne(lineEdit);
1203 toCC->show();
1204 lineEdit->show();
1205 setTabOrder(formPredecessor(ui->envelopeLayout, toCC), toCC);
1206 setTabOrder(toCC, lineEdit);
1207 if (toCC == m_lastFocusedRecipient)
1208 toCC->setFocus();
1209 else if (lineEdit == m_lastFocusedRecipient)
1210 lineEdit->setFocus();
1213 if (m_lastFocusedRecipient && !m_lastFocusedRecipient->hasFocus() && QApplication::focusWidget())
1214 ui->envelopeWidget->setFocus();
1216 Q_FOREACH (QWidget *w, visibleWidgets) {
1217 // was visible, is no longer -> stop animation so it won't conflict later ones
1218 w->setGraphicsEffect(0); // deletes old one
1219 if (QPropertyAnimation *pa = w->findChild<QPropertyAnimation*>(trojita_opacityAnimation))
1220 pa->stop();
1222 connect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
1225 void ComposeWidget::collapseRecipients()
1227 QLineEdit *edit = qobject_cast<QLineEdit*>(sender());
1228 Q_ASSERT(edit);
1229 if (edit->hasFocus() || !edit->text().isEmpty())
1230 return; // nothing to clean up
1232 // an empty recipient line just lost focus -> we "place it at the end", ie. simply remove it
1233 // and append a clone
1234 bool needEmpty = false;
1235 Composer::RecipientKind carriedKind = recipientKindForNextRow(Composer::ADDRESS_TO);
1236 for (int i = 0; i < m_recipients.count() - 1; ++i) { // sic! on the -1, no action if it trails anyway
1237 if (m_recipients.at(i).second == edit) {
1238 carriedKind = currentRecipient(m_recipients.last().first);
1239 removeRecipient(i);
1240 needEmpty = true;
1241 break;
1244 if (needEmpty)
1245 addRecipient(m_recipients.count(), carriedKind, QString());
1248 void ComposeWidget::gotError(const QString &error)
1250 QMessageBox::critical(this, tr("Failed to Send Mail"), error);
1251 setUiWidgetsEnabled(true);
1254 void ComposeWidget::sent()
1256 // FIXME: move back to the currently selected mailbox
1258 m_sentMail = true;
1259 QTimer::singleShot(0, this, SLOT(close()));
1262 bool ComposeWidget::parseRecipients(QList<QPair<Composer::RecipientKind, Imap::Message::MailAddress> > &results, QString &errorMessage)
1264 for (int i = 0; i < m_recipients.size(); ++i) {
1265 Composer::RecipientKind kind = currentRecipient(m_recipients.at(i).first);
1267 QString text = m_recipients.at(i).second->text();
1268 if (text.isEmpty())
1269 continue;
1270 Imap::Message::MailAddress addr;
1271 bool ok = Imap::Message::MailAddress::fromPrettyString(addr, text);
1272 if (ok) {
1273 // TODO: should we *really* learn every junk entered into a recipient field?
1274 // m_mainWindow->addressBook()->learn(addr);
1275 results << qMakePair(kind, addr);
1276 } else {
1277 errorMessage = tr("Can't parse \"%1\" as an e-mail address.").arg(text);
1278 return false;
1281 return true;
1284 void ComposeWidget::completeRecipients(const QString &text)
1286 if (text.isEmpty()) {
1287 // if there's a popup close it and set back the receiver
1288 m_completionPopup->close();
1289 m_completionReceiver = 0;
1290 return; // we do not suggest "nothing"
1292 Q_ASSERT(sender());
1293 QLineEdit *toEdit = qobject_cast<QLineEdit*>(sender());
1294 Q_ASSERT(toEdit);
1296 Plugins::AddressbookJob *firstJob = m_firstCompletionRequests.take(toEdit);
1297 Plugins::AddressbookJob *secondJob = m_secondCompletionRequests.take(toEdit);
1299 // if two jobs are running, first was started before second so first should finish earlier
1300 // stop second job
1301 if (firstJob && secondJob) {
1302 disconnect(secondJob, nullptr, this, nullptr);
1303 secondJob->stop();
1304 secondJob->deleteLater();
1305 secondJob = 0;
1307 // now at most one job is running
1309 Plugins::AddressbookPlugin *addressbook = m_mainWindow->pluginManager()->addressbook();
1310 if (!addressbook || !(addressbook->features() & Plugins::AddressbookPlugin::FeatureCompletion))
1311 return;
1313 auto newJob = addressbook->requestCompletion(text, QStringList(), m_completionCount);
1315 if (!newJob)
1316 return;
1318 if (secondJob) {
1319 // if only second job is running move second to first and push new as second
1320 firstJob = secondJob;
1321 secondJob = newJob;
1322 } else if (firstJob) {
1323 // if only first job is running push new job as second
1324 secondJob = newJob;
1325 } else {
1326 // if no jobs is running push new job as first
1327 firstJob = newJob;
1330 if (firstJob)
1331 m_firstCompletionRequests.insert(toEdit, firstJob);
1333 if (secondJob)
1334 m_secondCompletionRequests.insert(toEdit, secondJob);
1336 connect(newJob, &Plugins::AddressbookCompletionJob::completionAvailable, this, &ComposeWidget::onCompletionAvailable);
1337 connect(newJob, &Plugins::AddressbookCompletionJob::error, this, &ComposeWidget::onCompletionFailed);
1339 newJob->setAutoDelete(true);
1340 newJob->start();
1343 void ComposeWidget::onCompletionFailed(Plugins::AddressbookJob::Error error)
1345 Q_UNUSED(error);
1346 onCompletionAvailable(Plugins::NameEmailList());
1349 void ComposeWidget::onCompletionAvailable(const Plugins::NameEmailList &completion)
1351 Plugins::AddressbookJob *job = qobject_cast<Plugins::AddressbookJob *>(sender());
1352 Q_ASSERT(job);
1353 QLineEdit *toEdit = m_firstCompletionRequests.key(job);
1355 if (!toEdit)
1356 toEdit = m_secondCompletionRequests.key(job);
1358 if (!toEdit)
1359 return;
1361 // jobs are removed from QMap below
1362 Plugins::AddressbookJob *firstJob = m_firstCompletionRequests.value(toEdit);
1363 Plugins::AddressbookJob *secondJob = m_secondCompletionRequests.value(toEdit);
1365 if (job == secondJob) {
1366 // second job finished before first and first was started before second
1367 // so stop first because it has old data
1368 if (firstJob) {
1369 disconnect(firstJob, nullptr, this, nullptr);
1370 firstJob->stop();
1371 firstJob->deleteLater();
1372 firstJob = nullptr;
1374 m_firstCompletionRequests.remove(toEdit);
1375 m_secondCompletionRequests.remove(toEdit);
1376 } else if (job == firstJob) {
1377 // first job finished, but if second is still running it will have new data, so do not stop it
1378 m_firstCompletionRequests.remove(toEdit);
1381 QStringList contacts;
1383 for (int i = 0; i < completion.size(); ++i) {
1384 const Plugins::NameEmail &item = completion.at(i);
1385 contacts << Imap::Message::MailAddress::fromNameAndMail(item.name, item.email).asPrettyString();
1388 if (contacts.isEmpty()) {
1389 m_completionReceiver = 0;
1390 m_completionPopup->close();
1391 } else {
1392 m_completionReceiver = toEdit;
1393 m_completionPopup->setUpdatesEnabled(false);
1394 m_completionPopup->clear();
1395 Q_FOREACH(const QString &s, contacts)
1396 m_completionPopup->addAction(s);
1397 if (m_completionPopup->isHidden())
1398 m_completionPopup->popup(toEdit->mapToGlobal(QPoint(0, toEdit->height())));
1399 m_completionPopup->setUpdatesEnabled(true);
1403 void ComposeWidget::completeRecipient(QAction *act)
1405 if (act->text().isEmpty())
1406 return;
1407 m_completionReceiver->setText(act->text());
1408 m_completionReceiver = 0;
1409 m_completionPopup->close();
1412 bool ComposeWidget::eventFilter(QObject *o, QEvent *e)
1414 if (o == m_completionPopup) {
1415 if (!m_completionPopup->isVisible())
1416 return false;
1418 if (e->type() == QEvent::KeyPress || e->type() == QEvent::KeyRelease) {
1419 QKeyEvent *ke = static_cast<QKeyEvent*>(e);
1420 if (!( ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down || // Navigation
1421 ke->key() == Qt::Key_Escape || // "escape"
1422 ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) { // selection
1423 Q_ASSERT(m_completionReceiver);
1424 QCoreApplication::sendEvent(m_completionReceiver, e);
1425 return true;
1428 return false;
1431 if (o == ui->envelopeWidget) {
1432 if (e->type() == QEvent::Wheel) {
1433 int v = ui->recipientSlider->value();
1434 if (static_cast<QWheelEvent*>(e)->delta() > 0)
1435 --v;
1436 else
1437 ++v;
1438 // just QApplication::sendEvent(ui->recipientSlider, e) will cause a recursion if
1439 // ui->recipientSlider ignores the event (eg. because it would lead to an invalid value)
1440 // since ui->recipientSlider is child of ui->envelopeWidget
1441 // my guts tell me to not send events to children if it can be avoided, but its just a gut feeling
1442 ui->recipientSlider->setValue(v);
1443 e->accept();
1444 return true;
1446 if (e->type() == QEvent::KeyPress && ui->envelopeWidget->hasFocus()) {
1447 scrollToFocus();
1448 QWidget *focus = QApplication::focusWidget();
1449 if (focus && focus != ui->envelopeWidget) {
1450 int key = static_cast<QKeyEvent*>(e)->key();
1451 if (!(key == Qt::Key_Tab || key == Qt::Key_Backtab)) // those alter the focus again
1452 QApplication::sendEvent(focus, e);
1454 return true;
1456 if (e->type() == QEvent::Resize) {
1457 QResizeEvent *re = static_cast<QResizeEvent*>(e);
1458 if (re->size().height() != re->oldSize().height())
1459 calculateMaxVisibleRecipients();
1460 return false;
1462 return false;
1465 return false;
1469 void ComposeWidget::slotAskForFileAttachment()
1471 static QDir directory = QDir::home();
1472 QString fileName = QFileDialog::getOpenFileName(this, tr("Attach File..."), directory.absolutePath(), QString(), 0,
1473 QFileDialog::DontResolveSymlinks);
1474 if (!fileName.isEmpty()) {
1475 directory = QFileInfo(fileName).absoluteDir();
1476 m_submission->composer()->addFileAttachment(fileName);
1480 void ComposeWidget::slotAttachFiles(QList<QUrl> urls)
1482 foreach (const QUrl &url, urls) {
1483 if (url.isLocalFile()) {
1484 m_submission->composer()->addFileAttachment(url.path());
1489 void ComposeWidget::slotUpdateSignature()
1491 InhibitComposerDirtying inhibitor(this);
1492 QAbstractProxyModel *proxy = qobject_cast<QAbstractProxyModel*>(ui->sender->model());
1493 Q_ASSERT(proxy);
1494 QModelIndex proxyIndex = ui->sender->model()->index(ui->sender->currentIndex(), 0, ui->sender->rootModelIndex());
1496 if (!proxyIndex.isValid()) {
1497 // This happens when the settings dialog gets closed and the SenderIdentitiesModel reloads data from the on-disk cache
1498 return;
1501 QString newSignature = proxy->mapToSource(proxyIndex).sibling(proxyIndex.row(),
1502 Composer::SenderIdentitiesModel::COLUMN_SIGNATURE)
1503 .data().toString();
1505 Composer::Util::replaceSignature(ui->mailText->document(), newSignature);
1508 /** @short Massage the list of recipients so that they match the desired type of reply
1510 In case of an error, the original list of recipients is left as is.
1512 bool ComposeWidget::setReplyMode(const Composer::ReplyMode mode)
1514 if (!m_replyingToMessage.isValid())
1515 return false;
1517 // Determine the new list of recipients
1518 Composer::RecipientList list;
1519 if (!Composer::Util::replyRecipientList(mode, m_mainWindow->senderIdentitiesModel(),
1520 m_replyingToMessage, list)) {
1521 return false;
1524 while (!m_recipients.isEmpty())
1525 removeRecipient(0);
1527 Q_FOREACH(Composer::RecipientList::value_type recipient, list) {
1528 if (!recipient.second.hasUsefulDisplayName())
1529 recipient.second.name.clear();
1530 addRecipient(m_recipients.size(), recipient.first, recipient.second.asPrettyString());
1533 updateRecipientList();
1535 switch (mode) {
1536 case Composer::REPLY_PRIVATE:
1537 m_actionReplyModePrivate->setChecked(true);
1538 break;
1539 case Composer::REPLY_ALL_BUT_ME:
1540 m_actionReplyModeAllButMe->setChecked(true);
1541 break;
1542 case Composer::REPLY_ALL:
1543 m_actionReplyModeAll->setChecked(true);
1544 break;
1545 case Composer::REPLY_LIST:
1546 m_actionReplyModeList->setChecked(true);
1547 break;
1550 m_replyModeButton->setText(m_replyModeActions->checkedAction()->text());
1551 m_replyModeButton->setIcon(m_replyModeActions->checkedAction()->icon());
1553 ui->mailText->setFocus();
1555 return true;
1558 /** local draft serializaton:
1559 * Version (int)
1560 * Whether this draft was stored explicitly (bool)
1561 * The sender (QString)
1562 * Amount of recipients (int)
1563 * n * (RecipientKind ("int") + recipient (QString))
1564 * Subject (QString)
1565 * The message text (QString)
1568 void ComposeWidget::saveDraft(const QString &path)
1570 static const int trojitaDraftVersion = 3;
1571 QFile file(path);
1572 if (!file.open(QIODevice::WriteOnly))
1573 return; // TODO: error message?
1574 QDataStream stream(&file);
1575 stream.setVersion(QDataStream::Qt_4_6);
1576 stream << trojitaDraftVersion << m_explicitDraft << ui->sender->currentText();
1577 stream << m_recipients.count();
1578 for (int i = 0; i < m_recipients.count(); ++i) {
1579 stream << m_recipients.at(i).first->itemData(m_recipients.at(i).first->currentIndex()).toInt();
1580 stream << m_recipients.at(i).second->text();
1582 stream << m_submission->composer()->timestamp() << m_inReplyTo << m_references;
1583 stream << m_actionInReplyTo->isChecked();
1584 stream << ui->subject->text();
1585 stream << ui->mailText->toPlainText();
1586 // we spare attachments
1587 // a) serializing isn't an option, they could be HUUUGE
1588 // b) storing urls only works for urls
1589 // c) the data behind the url or the url validity might have changed
1590 // d) nasty part is writing mails - DnD a file into it is not a problem
1591 file.close();
1592 file.setPermissions(QFile::ReadOwner|QFile::WriteOwner);
1596 * When loading a draft we omit the present autostorage (content is replaced anyway) and make
1597 * the loaded path the autosave path, so all further automatic storage goes into the present
1598 * draft file
1601 void ComposeWidget::loadDraft(const QString &path)
1603 QFile file(path);
1604 if (!file.open(QIODevice::ReadOnly))
1605 return;
1607 if (m_autoSavePath != path) {
1608 QFile::remove(m_autoSavePath);
1609 m_autoSavePath = path;
1612 QDataStream stream(&file);
1613 stream.setVersion(QDataStream::Qt_4_6);
1614 QString string;
1615 int version, recipientCount;
1616 stream >> version;
1617 stream >> m_explicitDraft;
1618 stream >> string >> recipientCount; // sender / amount of recipients
1619 int senderIndex = ui->sender->findText(string);
1620 if (senderIndex != -1) {
1621 ui->sender->setCurrentIndex(senderIndex);
1622 } else {
1623 ui->sender->setEditText(string);
1625 for (int i = 0; i < recipientCount; ++i) {
1626 int kind;
1627 stream >> kind >> string;
1628 if (!string.isEmpty())
1629 addRecipient(i, static_cast<Composer::RecipientKind>(kind), string);
1631 if (version >= 2) {
1632 QDateTime timestamp;
1633 stream >> timestamp >> m_inReplyTo >> m_references;
1634 m_submission->composer()->setTimestamp(timestamp);
1635 if (!m_inReplyTo.isEmpty()) {
1636 m_markButton->show();
1637 // FIXME: in-reply-to's validitiy isn't the best check for showing or not showing the reply mode.
1638 // For eg: consider cases of mailto, forward, where valid in-reply-to won't mean choice of reply modes.
1639 m_replyModeButton->show();
1641 m_actionReplyModeAll->setEnabled(false);
1642 m_actionReplyModeAllButMe->setEnabled(false);
1643 m_actionReplyModeList->setEnabled(false);
1644 m_actionReplyModePrivate->setEnabled(false);
1645 markReplyModeHandpicked();
1647 // We do not have the message index at this point, but we can at least show the Message-Id here
1648 QStringList inReplyTo;
1649 Q_FOREACH(auto item, m_inReplyTo) {
1650 // There's no HTML escaping to worry about
1651 inReplyTo << QLatin1Char('<') + QString::fromUtf8(item.constData()) + QLatin1Char('>');
1653 m_actionInReplyTo->setToolTip(tr("This mail will be marked as a response<hr/>%1").arg(
1654 inReplyTo.join(tr("<br/>")).toHtmlEscaped()
1656 if (version == 2) {
1657 // it is always marked as a reply in v2
1658 m_actionInReplyTo->trigger();
1662 if (version >= 3) {
1663 bool replyChecked;
1664 stream >> replyChecked;
1665 // Got to use trigger() so that the default action of the QToolButton is updated
1666 if (replyChecked) {
1667 m_actionInReplyTo->trigger();
1668 } else {
1669 m_actionStandalone->trigger();
1672 stream >> string;
1673 ui->subject->setText(string);
1674 stream >> string;
1675 ui->mailText->setPlainText(string);
1676 m_saveState->setMessageUpdated(false); // this is now the most up-to-date one
1677 file.close();
1680 void ComposeWidget::autoSaveDraft()
1682 if (m_saveState->updated()) {
1683 m_saveState->setMessageUpdated(false);
1684 saveDraft(m_autoSavePath);
1688 void ComposeWidget::setMessageUpdated()
1690 m_saveState->setMessageUpdated(true);
1691 m_saveState->setMessageEverEdited(true);
1694 void ComposeWidget::updateWindowTitle()
1696 if (ui->subject->text().isEmpty()) {
1697 setWindowTitle(tr("Compose Mail"));
1698 } else {
1699 setWindowTitle(tr("%1 - Compose Mail").arg(ui->subject->text()));
1703 void ComposeWidget::toggleReplyMarking()
1705 (m_actionInReplyTo->isChecked() ? m_actionStandalone : m_actionInReplyTo)->trigger();
1708 void ComposeWidget::updateReplyMarkingAction()
1710 auto action = m_markAsReply->checkedAction();
1711 m_actionToggleMarking->setText(action->text());
1712 m_actionToggleMarking->setIcon(action->icon());
1713 m_actionToggleMarking->setToolTip(action->toolTip());