diff --git a/components/kube/qml/Kube.qml b/components/kube/qml/Kube.qml index 76fd063a..147d0ae7 100644 --- a/components/kube/qml/Kube.qml +++ b/components/kube/qml/Kube.qml @@ -1,324 +1,324 @@ /* * Copyright (C) 2017 Michael Bohlender, * Copyright (C) 2017 Christian Mollekopf, * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * 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. */ import QtQuick 2.7 import QtQuick.Layouts 1.3 import QtQuick.Window 2.0 import QtQuick.Controls 2.0 as Controls2 import org.kube.framework 1.0 as Kube Controls2.ApplicationWindow { id: app property int sidebarWidth: Kube.Units.gridUnit + Kube.Units.largeSpacing height: Screen.desktopAvailableHeight * 0.8 width: Screen.desktopAvailableWidth * 0.8 visible: true //Application default font font.family: Kube.Font.fontFamily //Application context property variant currentAccount onCurrentAccountChanged: { if (!!currentAccount) { Kube.Fabric.postMessage(Kube.Messages.synchronize, {"accountId": currentAccount}) } } //Interval sync Timer { id: intervalSync //5min interval: 300000 running: true repeat: true onTriggered: { if (kubeViews.currentItem && kubeViews.currentItem.refresh) { kubeViews.currentItem.refresh() } } } Kube.StartupCheck { id: startupCheck } Accounts { } //Listener Kube.Listener { filter: Kube.Messages.accountSelection onMessageReceived: app.currentAccount = message.accountId } Kube.Listener { filter: Kube.Messages.folderSelection onMessageReceived: Kube.Fabric.postMessage(Kube.Messages.synchronize, {"folder": message.folder}) } Kube.Listener { filter: Kube.Messages.notification onMessageReceived: { if (message.message) { notificationPopup.notify(message.message); } } } //BEGIN Shortcuts Shortcut { sequence: StandardKey.Quit onActivated: Qt.quit() } Shortcut { onActivated: Kube.Fabric.postMessage(Kube.Messages.search, {}) sequence: StandardKey.Find } Shortcut { id: syncShortcut sequence: StandardKey.Refresh onActivated: { if (kubeViews.currentItem && kubeViews.currentItem.refresh) { kubeViews.currentItem.refresh() } } } //END Shortcuts //BEGIN background Rectangle { anchors.fill: parent color: Kube.Colors.backgroundColor } //END background //BEGIN Main content RowLayout { id: mainContent spacing: 0 anchors.fill: parent Rectangle { id: sideBar anchors { top: mainContent.top bottom: mainContent.bottom } width: app.sidebarWidth color: Kube.Colors.darkBackgroundColor Rectangle { anchors.right: parent.right width: 1 height: parent.height color: Kube.Colors.viewBackgroundColor opacity: 0.3 } Controls2.ButtonGroup { id: viewButtonGroup } Column { anchors { top: parent.top topMargin: Kube.Units.smallSpacing horizontalCenter: parent.horizontalCenter } spacing: Kube.Units.largeSpacing - Kube.Units.smallSpacing Repeater { model: Kube.ExtensionModel { id: extensionModel extensionPoint: "views" sortOrder: ["search", "composer", "conversation", "people"] } Kube.IconButton { id: button iconName: model.icon onClicked: kubeViews.showView(model.name) activeFocusOnTab: true checkable: true Controls2.ButtonGroup.group: viewButtonGroup tooltip: model.tooltip checked: kubeViews.currentViewName == model.name } } } Column { anchors { bottom: parent.bottom bottomMargin: Kube.Units.smallSpacing horizontalCenter: parent.horizontalCenter } spacing: Kube.Units.largeSpacing - Kube.Units.smallSpacing Kube.Outbox { height: Kube.Units.gridUnit * 1.5 width: height Kube.ToolTip { text: qsTr("Outbox") visible: parent.hovered } } Kube.IconButton { id: logButton iconName: Kube.Icons.info_inverted onClicked: kubeViews.showView("log") activeFocusOnTab: true checkable: true visible: false Kube.Listener { filter: Kube.Messages.errorPending onMessageReceived: logButton.alert = message.errorPending } Kube.Listener { filter: Kube.Messages.notificationPending onMessageReceived: logButton.visible = true } checked: kubeViews.currentViewName == "log" Controls2.ButtonGroup.group: viewButtonGroup tooltip: qsTr("Notification View") } Kube.IconButton { id: accountsButton iconName: Kube.Icons.menu_inverted onClicked: kubeViews.showView("accounts") activeFocusOnTab: true checkable: true checked: kubeViews.currentViewName == "accounts" Controls2.ButtonGroup.group: viewButtonGroup tooltip: qsTr("Settings") } } } ViewManager { id: kubeViews anchors { top: mainContent.top bottom: mainContent.bottom } Layout.fillWidth: true extensionModel: extensionModel Component.onCompleted: { dontFocus = true prepareViewInBackground("log", {}) showView("conversation") if (startupCheck.noAccount) { showView("accounts") } dontFocus = false } Kube.Listener { filter: Kube.Messages.reply - onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Reply}) + onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Reply, accountId: app.currentAccount}) } Kube.Listener { filter: Kube.Messages.forward - onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Forward}) + onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Forward, accountId: app.currentAccount}) } Kube.Listener { filter: Kube.Messages.edit - onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Draft}) + onMessageReceived: kubeViews.replaceView("composer", {message: message.mail, loadType: Kube.ComposerController.Draft, accountId: app.currentAccount}) } Kube.Listener { filter: Kube.Messages.compose - onMessageReceived: kubeViews.replaceView("composer", {newMessage: true, recipients: message.recipients}) + onMessageReceived: kubeViews.replaceView("composer", {newMessage: true, recipients: message.recipients, accountId: app.currentAccount}) } Kube.Listener { filter: Kube.Messages.requestAccountsConfiguration onMessageReceived: kubeViews.showView("accounts") } Kube.Listener { filter: Kube.Messages.componentDone onMessageReceived: { kubeViews.closeView() } } Kube.Listener { filter: Kube.Messages.requestLogin onMessageReceived: { var view = loginView.createObject(kubeViews, {accountId: message.accountId}) view.forceActiveFocus() } } Component { id: loginView Kube.Popup { id: popup property alias accountId: login.accountId visible: true parent: Controls2.ApplicationWindow.overlay height: app.height width: app.width - app.sidebarWidth x: app.sidebarWidth y: 0 modal: true closePolicy: Controls2.Popup.NoAutoClose Kube.LoginAccount { id: login anchors { fill: parent bottomMargin: Kube.Units.largeSpacing } onDone: { kubeViews.currentItem.forceActiveFocus() popup.destroy() } } } } } } //END Main content //BEGIN Notification Kube.NotificationPopup { id: notificationPopup anchors { left: parent.left leftMargin: app.sidebarWidth - 3 // so it does not align with the border bottom: parent.bottom bottomMargin: Kube.Units.gridUnit * 4 } } //END Notification } diff --git a/framework/src/domain/composercontroller.cpp b/framework/src/domain/composercontroller.cpp index 80fab809..573e695b 100644 --- a/framework/src/domain/composercontroller.cpp +++ b/framework/src/domain/composercontroller.cpp @@ -1,577 +1,590 @@ /* Copyright (c) 2016 Michael Bohlender Copyright (c) 2016 Christian Mollekopf This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library 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 Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; see the file COPYING.LIB. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "composercontroller.h" #include #include #include #include #include #include #include #include #include #include #include #include "identitiesmodel.h" #include "recepientautocompletionmodel.h" #include "mime/mailtemplates.h" #include "mime/mailcrypto.h" #include "async.h" std::vector &operator+=(std::vector &list, const std::vector &add) { list.insert(std::end(list), std::begin(add), std::end(add)); return list; } class IdentitySelector : public Selector { + Q_OBJECT + Q_PROPERTY (QString currentAccountId WRITE setCurrentAccountId) + public: IdentitySelector(ComposerController &controller) : Selector(new IdentitiesModel), mController(controller) { } void setCurrent(const QModelIndex &index) Q_DECL_OVERRIDE { if (index.isValid()) { auto currentAccountId = index.data(IdentitiesModel::AccountId).toByteArray(); KMime::Types::Mailbox mb; mb.setName(index.data(IdentitiesModel::Username).toString()); mb.setAddress(index.data(IdentitiesModel::Address).toString().toUtf8()); SinkLog() << "Setting current identity: " << mb.prettyAddress() << "Account: " << currentAccountId; mController.setIdentity(mb); mController.setAccountId(currentAccountId); } else { SinkWarning() << "No valid identity for index: " << index; mController.clearIdentity(); mController.clearAccountId(); } } + void setCurrentAccountId(const QString &accountId) + { + for (int i = 0; i < model()->rowCount(); i++) { + if (model()->index(i, 0).data(IdentitiesModel::AccountId).toString() == accountId) { + setCurrentIndex(i); + return; + } + } + } + QVector getAllAddresses() { QVector list; for (int i = 0; i < model()->rowCount(); i++) { list << model()->data(model()->index(i, 0), IdentitiesModel::Address).toString().toUtf8(); } return list; } private: ComposerController &mController; }; class RecipientCompleter : public Completer { public: RecipientCompleter() : Completer(new RecipientAutocompletionModel) { } void setSearchString(const QString &s) { static_cast(model())->setFilter(s); Completer::setSearchString(s); } }; class AddresseeController : public Kube::ListPropertyController { Q_OBJECT Q_PROPERTY(bool foundAllKeys READ foundAllKeys NOTIFY foundAllKeysChanged) public: bool mFoundAllKeys = true; QSet mMissingKeys; AddresseeController() : Kube::ListPropertyController{{"name", "keyFound", "key"}} { QObject::connect( this, &Kube::ListPropertyController::added, this, [this](const QByteArray &id, const QVariantMap &map) { findKey(id, map.value("name").toString()); }); QObject::connect(this, &Kube::ListPropertyController::removed, this, [this] (const QByteArray &id) { mMissingKeys.remove(id); setFoundAllKeys(mMissingKeys.isEmpty()); }); } bool foundAllKeys() { return mFoundAllKeys; } void setFoundAllKeys(bool found) { mFoundAllKeys = found; emit foundAllKeysChanged(); } void findKey(const QByteArray &id, const QString &addressee) { mMissingKeys << id; setFoundAllKeys(mMissingKeys.isEmpty()); KMime::Types::Mailbox mb; mb.fromUnicodeString(addressee); SinkLog() << "Searching key for: " << mb.address(); asyncRun>(this, [mb] { return Crypto::findKeys(QStringList{} << mb.address(), false, false); }, [this, addressee, id](const std::vector &keys) { if (!keys.empty()) { if (keys.size() > 1) { SinkWarning() << "Found more than one key, encrypting to all of them."; } SinkLog() << "Found key: " << keys.front(); setValue(id, "keyFound", true); setValue(id, "key", QVariant::fromValue(keys)); mMissingKeys.remove(id); setFoundAllKeys(mMissingKeys.isEmpty()); } else { SinkWarning() << "Failed to find key for recipient."; } }); } void set(const QStringList &list) { for (const auto &email : list) { add({{"name", email}}); } } signals: void foundAllKeysChanged(); }; class AttachmentController : public Kube::ListPropertyController { public: AttachmentController() : Kube::ListPropertyController{{"name", "filename", "content", "mimetype", "description", "iconname", "url", "inline"}} { QObject::connect(this, &Kube::ListPropertyController::added, this, [this] (const QByteArray &id, const QVariantMap &map) { auto url = map.value("url").toUrl(); setAttachmentProperties(id, url); }); } void setAttachmentProperties(const QByteArray &id, const QUrl &url) { QMimeDatabase db; auto mimeType = db.mimeTypeForUrl(url); if (mimeType.name() == QLatin1String("inode/directory")) { qWarning() << "Can't deal with directories yet."; } else { if (!url.isLocalFile()) { qWarning() << "Cannot attach remote file: " << url; return; } QFileInfo fileInfo(url.toLocalFile()); if (!fileInfo.exists()) { qWarning() << "The file doesn't exist: " << url; } QFile file{fileInfo.filePath()}; file.open(QIODevice::ReadOnly); const auto data = file.readAll(); QVariantMap map; map.insert("filename", fileInfo.fileName()); map.insert("mimetype", mimeType.name().toLatin1()); map.insert("filename", fileInfo.fileName().toLatin1()); map.insert("inline", false); map.insert("iconname", mimeType.iconName()); map.insert("url", url); map.insert("content", data); setValues(id, map); } } }; ComposerController::ComposerController() : Kube::Controller(), controller_to{new AddresseeController}, controller_cc{new AddresseeController}, controller_bcc{new AddresseeController}, controller_attachments{new AttachmentController}, action_send{new Kube::ControllerAction{this, &ComposerController::send}}, action_saveAsDraft{new Kube::ControllerAction{this, &ComposerController::saveAsDraft}}, mRecipientCompleter{new RecipientCompleter}, mIdentitySelector{new IdentitySelector{*this}} { QObject::connect(this, &ComposerController::identityChanged, &ComposerController::findPersonalKey); } void ComposerController::findPersonalKey() { auto identity = getIdentity(); SinkLog() << "Looking for personal key for: " << identity.address(); asyncRun>(this, [=] { return Crypto::findKeys(QStringList{} << identity.address(), true); }, [this](const std::vector &keys) { if (keys.empty()) { SinkWarning() << "Failed to find a personal key."; } else if (keys.size() > 1) { SinkWarning() << "Found multiple keys, using all of them."; } setPersonalKeys(QVariant::fromValue(keys)); setFoundPersonalKeys(!keys.empty()); }); } void ComposerController::clear() { Controller::clear(); //Reapply account and identity from selection mIdentitySelector->reapplyCurrentIndex(); //FIXME implement in Controller::clear instead toController()->clear(); ccController()->clear(); bccController()->clear(); } Completer *ComposerController::recipientCompleter() const { return mRecipientCompleter.data(); } Selector *ComposerController::identitySelector() const { return mIdentitySelector.data(); } static void applyAddresses(const KMime::Types::Mailbox::List &list, std::function callback) { for (const auto &to : list) { callback(to.address(), to.name().toUtf8()); } } static void applyAddresses(const QStringList &list, std::function callback) { KMime::Types::Mailbox::List mailboxes; for (const auto &s : list) { KMime::Types::Mailbox mb; mb.fromUnicodeString(s); mailboxes << mb; } applyAddresses(mailboxes, callback); } static QStringList getStringListFromAddresses(const KMime::Types::Mailbox::List &s) { QStringList list; applyAddresses(s, [&](const QByteArray &addrSpec, const QByteArray &displayName) { if (displayName.isEmpty()) { list << QString{addrSpec}; } else { list << QString("%1 <%2>").arg(QString{displayName}).arg(QString{addrSpec}); } }); return list; } void ComposerController::addAttachmentPart(KMime::Content *partToAttach) { QVariantMap map; // May need special care for the multipart/digest MIME type map.insert("content", partToAttach->decodedContent()); map.insert("mimetype", partToAttach->contentType()->mimeType()); QMimeDatabase db; auto mimeType = db.mimeTypeForName(partToAttach->contentType()->mimeType()); map.insert("iconname", mimeType.iconName()); if (partToAttach->contentDescription(false)) { map.insert("description", partToAttach->contentDescription()->asUnicodeString()); } QString name; QString filename; if (partToAttach->contentType(false)) { if (partToAttach->contentType()->hasParameter(QStringLiteral("name"))) { name = partToAttach->contentType()->parameter(QStringLiteral("name")); } } if (partToAttach->contentDisposition(false)) { filename = partToAttach->contentDisposition()->filename(); map.insert("inline", partToAttach->contentDisposition()->disposition() == KMime::Headers::CDinline); } if (name.isEmpty() && !filename.isEmpty()) { name = filename; } if (filename.isEmpty() && !name.isEmpty()) { filename = name; } if (!filename.isEmpty()) { map.insert("filename", filename); } if (!name.isEmpty()) { map.insert("name", name); } attachmentsController()->add(map); } void ComposerController::setMessage(const KMime::Message::Ptr &msg) { static_cast(toController())->set(getStringListFromAddresses(msg->to(true)->mailboxes())); static_cast(ccController())->set(getStringListFromAddresses(msg->cc(true)->mailboxes())); static_cast(bccController())->set(getStringListFromAddresses(msg->bcc(true)->mailboxes())); setSubject(msg->subject(true)->asUnicodeString()); bool isHtml = false; const auto body = MailTemplates::body(msg, isHtml); setHtmlBody(isHtml); setBody(body); //TODO use ObjecTreeParser to get encrypted attachments as well foreach (const auto &att, msg->attachments()) { addAttachmentPart(att); } setExistingMessage(msg); emit messageLoaded(body); } void ComposerController::loadDraft(const QVariant &message) { loadMessage(message, [this] (const KMime::Message::Ptr &mail) { mRemoveDraft = true; setMessage(mail); }); } void ComposerController::loadReply(const QVariant &message) { loadMessage(message, [this] (const KMime::Message::Ptr &mail) { //Find all personal email addresses to exclude from reply KMime::Types::AddrSpecList me; auto list = static_cast(mIdentitySelector.data())->getAllAddresses(); for (const auto &a : list) { KMime::Types::Mailbox mb; mb.setAddress(a); me << mb.addrSpec(); } MailTemplates::reply(mail, [this] (const KMime::Message::Ptr &reply) { //We assume reply setMessage(reply); }, me); }); } void ComposerController::loadForward(const QVariant &message) { loadMessage(message, [this] (const KMime::Message::Ptr &mail) { MailTemplates::forward(mail, [this] (const KMime::Message::Ptr &fwdMessage) { setMessage(fwdMessage); }); }); } void ComposerController::loadMessage(const QVariant &message, std::function callback) { using namespace Sink; using namespace Sink::ApplicationDomain; auto msg = message.value(); Q_ASSERT(msg); Query query(*msg); query.request(); Store::fetchOne(query).then([this, callback](const Mail &mail) { setExistingMail(mail); const auto mailData = KMime::CRLFtoLF(mail.getMimeMessage()); if (!mailData.isEmpty()) { KMime::Message::Ptr mail(new KMime::Message); mail->setContent(mailData); mail->parse(); callback(mail); } else { qWarning() << "Retrieved empty message"; } }).exec(); } void ComposerController::recordForAutocompletion(const QByteArray &addrSpec, const QByteArray &displayName) { if (auto model = static_cast(recipientCompleter()->model())) { model->addEntry(addrSpec, displayName); } } std::vector ComposerController::getRecipientKeys() { std::vector keys; { const auto list = toController()->getList>("key"); for (const auto &l: list) { keys.insert(std::end(keys), std::begin(l), std::end(l)); } } { const auto list = ccController()->getList>("key"); for (const auto &l: list) { keys.insert(std::end(keys), std::begin(l), std::end(l)); } } { const auto list = bccController()->getList>("key"); for (const auto &l: list) { keys.insert(std::end(keys), std::begin(l), std::end(l)); } } return keys; } KMime::Message::Ptr ComposerController::assembleMessage() { auto toAddresses = toController()->getList("name"); auto ccAddresses = ccController()->getList("name"); auto bccAddresses = bccController()->getList("name"); applyAddresses(toAddresses + ccAddresses + bccAddresses, [&](const QByteArray &addrSpec, const QByteArray &displayName) { recordForAutocompletion(addrSpec, displayName); }); QList attachments; attachmentsController()->traverse([&](const QVariantMap &value) { attachments << Attachment{ value["name"].toString(), value["filename"].toString(), value["mimetype"].toByteArray(), value["inline"].toBool(), value["content"].toByteArray() }; }); Crypto::Key attachedKey; std::vector signingKeys; if (getSign()) { signingKeys = getPersonalKeys().value>(); Q_ASSERT(!signingKeys.empty()); attachedKey = signingKeys[0]; } std::vector encryptionKeys; if (getEncrypt()) { //Encrypt to self so we can read the sent message auto personalKeys = getPersonalKeys().value>(); attachedKey = personalKeys[0]; encryptionKeys += personalKeys; encryptionKeys += getRecipientKeys(); } return MailTemplates::createMessage(mExistingMessage, toAddresses, ccAddresses, bccAddresses, getIdentity(), getSubject(), getBody(), getHtmlBody(), attachments, signingKeys, encryptionKeys, attachedKey); } void ComposerController::send() { auto message = assembleMessage(); if (!message) { SinkWarning() << "Failed to assemble the message."; return; } auto accountId = getAccountId(); //SinkLog() << "Sending a mail: " << *this; using namespace Sink; using namespace Sink::ApplicationDomain; Q_ASSERT(!accountId.isEmpty()); Query query; query.containsFilter(ResourceCapabilities::Mail::transport); query.filter(accountId); auto job = Store::fetchAll(query) .then([=](const QList &resources) { if (!resources.isEmpty()) { auto resourceId = resources[0]->identifier(); SinkLog() << "Sending message via resource: " << resourceId; Mail mail(resourceId); mail.setMimeMessage(message->encodedContent(true)); return Store::create(mail) .then([=] { //Trigger a sync, but don't wait for it. Store::synchronize(Sink::SyncScope{}.resourceFilter(resourceId)).exec(); if (mRemoveDraft) { SinkLog() << "Removing draft message."; //Remove draft Store::remove(getExistingMail()).exec(); } }); } SinkWarning() << "Failed to find a mailtransport resource"; return KAsync::error(0, "Failed to find a MailTransport resource."); }) .then([&] (const KAsync::Error &) { SinkLog() << "Message was sent: "; emit done(); }); run(job); } void ComposerController::saveAsDraft() { SinkLog() << "Save as draft"; const auto accountId = getAccountId(); auto existingMail = getExistingMail(); auto message = assembleMessage(); if (!message) { SinkWarning() << "Failed to assemble the message."; return; } using namespace Sink; using namespace Sink::ApplicationDomain; auto job = [&] { if (existingMail.identifier().isEmpty()) { SinkLog() << "Creating a new draft" << existingMail.identifier(); Query query; query.containsFilter(ResourceCapabilities::Mail::drafts); query.filter(accountId); return Store::fetchOne(query) .then([=](const SinkResource &resource) { Mail mail(resource.identifier()); mail.setDraft(true); mail.setMimeMessage(message->encodedContent(true)); return Store::create(mail); }) .onError([] (const KAsync::Error &error) { SinkWarning() << "Error while creating draft: " << error.errorMessage; }); } else { SinkLog() << "Modifying an existing mail" << existingMail.identifier(); existingMail.setDraft(true); existingMail.setMimeMessage(message->encodedContent(true)); return Store::modify(existingMail); } }(); job = job.then([&] (const KAsync::Error &) { emit done(); }); run(job); } #include "composercontroller.moc" diff --git a/framework/src/domain/selector.h b/framework/src/domain/selector.h index 358eaa9a..3e8b6589 100644 --- a/framework/src/domain/selector.h +++ b/framework/src/domain/selector.h @@ -1,56 +1,64 @@ /* Copyright (c) 2016 Christian Mollekofp This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library 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 Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; see the file COPYING.LIB. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #pragma once #include #include /** * Exposes a model and maintains a current index selection. */ class Selector : public QObject { Q_OBJECT - Q_PROPERTY (int currentIndex READ currentIndex WRITE setCurrentIndex) + Q_PROPERTY (int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) Q_PROPERTY (QAbstractItemModel* model READ model CONSTANT) public: Selector(QAbstractItemModel *model); virtual QAbstractItemModel *model() { return mModel; } void setCurrentIndex(int i) { + if (i == mCurrentIndex) { + return; + } mCurrentIndex = i; Q_ASSERT(mModel); if (i >= 0) { setCurrent(mModel->index(mCurrentIndex, 0)); } else { setCurrent(QModelIndex()); } + emit currentIndexChanged(); } void reapplyCurrentIndex(); int currentIndex() { return mCurrentIndex; } virtual void setCurrent(const QModelIndex &) = 0; + +signals: + void currentIndexChanged(); + private: QAbstractItemModel *mModel = nullptr; int mCurrentIndex = 0; }; diff --git a/views/composer/qml/View.qml b/views/composer/qml/View.qml index 35775d9e..b2e3fca4 100644 --- a/views/composer/qml/View.qml +++ b/views/composer/qml/View.qml @@ -1,559 +1,562 @@ /* * Copyright (C) 2017 Michael Bohlender, * Copyright (C) 2017 Christian Mollekopf, * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * 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. */ import QtQuick 2.7 import QtQuick.Controls 2.0 as Controls2 import QtQuick.Layouts 1.1 import QtQuick.Dialogs 1.0 as Dialogs import org.kube.framework 1.0 as Kube Kube.View { id: root visibleViews: 2 property bool newMessage: false property int loadType: Kube.ComposerController.Draft property variant message: {} property variant recipients: [] + property variant accountId: {} resources: [ Kube.ComposerController { id: composerController sign: signCheckbox.checked encrypt: encryptCheckbox.checked onDone: root.done() property bool foundAllKeys: composerController.to.foundAllKeys && composerController.cc.foundAllKeys && composerController.bcc.foundAllKeys sendAction.enabled: composerController.accountId && composerController.subject && (!composerController.encrypt || composerController.foundAllKeys) && (!composerController.sign && !composerController.encrypt || composerController.foundPersonalKeys) && !composerController.to.empty saveAsDraftAction.enabled: composerController.accountId onMessageLoaded: { textEditor.initialText = body } onCleared: { textEditor.initialText = "" } } ] onSetup: { loadMessage(root.message, root.loadType) + composerController.identitySelector.currentAccountId = root.accountId } onRefresh: { Kube.Fabric.postMessage(Kube.Messages.synchronize, {"type": "mail", "specialPurpose": "drafts"}) //For autocompletion Kube.Fabric.postMessage(Kube.Messages.synchronize, {"type": "contacts"}) } onAborted: { //Avoid loosing the message if (composerController.saveAsDraftAction.enabled) { composerController.saveAsDraftAction.execute() } } function loadMessage(message, loadType) { if (message) { switch(loadType) { case Kube.ComposerController.Draft: composerController.loadDraft(message) break; case Kube.ComposerController.Reply: composerController.loadReply(message) subject.forceActiveFocus() break; case Kube.ComposerController.Forward: composerController.loadForward(message) subject.forceActiveFocus() break; } } else if (newMessage) { composerController.clear() if (root.recipients) { for (var i = 0; i < root.recipients.length; ++i) { composerController.to.add({name: root.recipients[i]}) } } subject.forceActiveFocus() } } function closeFirstSplitIfNecessary() { //Move the view forward if (root.currentIndex == 0) { root.incrementCurrentIndex() } } //Drafts Rectangle { anchors { top: parent.top bottom: parent.bottom } width: Kube.Units.gridUnit * 15 Layout.minimumWidth: Kube.Units.gridUnit * 5 color: Kube.Colors.darkBackgroundColor ColumnLayout { anchors { fill: parent topMargin: Kube.Units.largeSpacing leftMargin: Kube.Units.largeSpacing } spacing: Kube.Units.largeSpacing Kube.PositiveButton { objectName: "newMailButton" width: parent.width - Kube.Units.largeSpacing focus: true text: qsTr("New Email") onClicked: { listView.currentIndex = -1 composerController.clear() subject.forceActiveFocus() } } Kube.Label{ text: qsTr("Drafts") color: Kube.Colors.highlightedTextColor font.weight: Font.Bold } Kube.ListView { id: listView activeFocusOnTab: true anchors { left: parent.left right: parent.right } Layout.fillHeight: true clip: true currentIndex: -1 highlightFollowsCurrentItem: false //BEGIN keyboard nav onActiveFocusChanged: { if (activeFocus && currentIndex < 0) { currentIndex = 0 } } Keys.onDownPressed: { listView.incrementCurrentIndex() } Keys.onUpPressed: { listView.decrementCurrentIndex() } //END keyboard nav onCurrentItemChanged: { if (currentItem) { root.loadMessage(currentItem.currentData.domainObject, Kube.ComposerController.Draft) } } model: Kube.MailListModel { id: mailListModel showDrafts: true } delegate: Kube.ListDelegate { id: delegateRoot color: Kube.Colors.darkBackgroundColor border.width: 0 Item { id: content anchors { fill: parent margins: Kube.Units.smallSpacing } Kube.Label { width: content.width - Kube.Units.largeSpacing text: model.subject == "" ? "no subject" : model.subject color: Kube.Colors.highlightedTextColor maximumLineCount: 2 wrapMode: Text.WrapAnywhere elide: Text.ElideRight } Kube.Label { anchors { right: parent.right rightMargin: Kube.Units.largeSpacing bottom: parent.bottom } text: Qt.formatDateTime(model.date, "dd MMM yyyy") font.italic: true color: Kube.Colors.disabledTextColor font.pointSize: Kube.Units.smallFontSize visible: !delegateRoot.hovered } } Row { id: buttons anchors { right: parent.right bottom: parent.bottom bottomMargin: Kube.Units.smallSpacing rightMargin: Kube.Units.largeSpacing } visible: delegateRoot.hovered spacing: Kube.Units.smallSpacing opacity: 0.7 Kube.IconButton { id: deleteButton activeFocusOnTab: true iconName: Kube.Icons.moveToTrash visible: enabled enabled: !!model.mail onClicked: Kube.Fabric.postMessage(Kube.Messages.moveToTrash, {"mail": model.mail}) } } } } } } //Content Rectangle { Layout.fillWidth: true Layout.minimumWidth: Kube.Units.gridUnit * 5 anchors { top: parent.top bottom: parent.bottom } color: Kube.Colors.backgroundColor ColumnLayout { anchors { fill: parent margins: Kube.Units.largeSpacing leftMargin: Kube.Units.largeSpacing + Kube.Units.gridUnit * 2 rightMargin: Kube.Units.largeSpacing + Kube.Units.gridUnit * 2 } spacing: Kube.Units.smallSpacing Kube.TextField { id: subject objectName: "subject" Layout.fillWidth: true activeFocusOnTab: true font.bold: true placeholderText: qsTr("Enter Subject...") text: composerController.subject onTextChanged: composerController.subject = text; onActiveFocusChanged: { if (activeFocus) { closeFirstSplitIfNecessary() } } } Flow { id: attachments Layout.fillWidth: true layoutDirection: Qt.RightToLeft spacing: Kube.Units.smallSpacing clip: true Repeater { model: composerController.attachments.model delegate: Kube.AttachmentDelegate { name: model.filename ? model.filename : "" icon: model.iconname ? model.iconname : "" clip: true actionIcon: Kube.Icons.remove onExecute: composerController.attachments.remove(model.id) } } } RowLayout { spacing: Kube.Units.largeSpacing Row { spacing: 1 Kube.IconButton { iconName: Kube.Icons.bold checkable: true checked: textEditor.bold onClicked: textEditor.bold = !textEditor.bold focusPolicy: Qt.TabFocus focus: false } Kube.IconButton { iconName: Kube.Icons.italic checkable: true checked: textEditor.italic onClicked: textEditor.italic = !textEditor.italic focusPolicy: Qt.TabFocus focus: false } Kube.IconButton { iconName: Kube.Icons.underline checkable: true checked: textEditor.underline onClicked: textEditor.underline = !textEditor.underline focusPolicy: Qt.TabFocus focus: false } Kube.TextButton { id: deleteButton text: qsTr("Remove Formatting") visible: textEditor.htmlEnabled onClicked: textEditor.clearFormatting() } } Item { height: 1 Layout.fillWidth: true } Kube.Button { text: qsTr("Attach file") onClicked: { fileDialog.open() } Dialogs.FileDialog { id: fileDialog title: qsTr("Choose a file to attach") folder: shortcuts.home selectFolder: false selectExisting: true selectMultiple: true onAccepted: { for (var i = 0; i < fileDialog.fileUrls.length; ++i) { composerController.attachments.add({url: fileDialog.fileUrls[i]}) } } } } } Kube.TextEditor { id: textEditor objectName: "textEditor" activeFocusOnTab: true Layout.fillWidth: true Layout.fillHeight: true onHtmlEnabledChanged: { composerController.htmlBody = htmlEnabled; } onActiveFocusChanged: closeFirstSplitIfNecessary() Keys.onEscapePressed: recipients.forceActiveFocus(Qt.TabFocusReason) onTextChanged: { composerController.body = text; } } } } //Recepients FocusScope { id: recipients anchors { top: parent.top bottom: parent.bottom } width: Kube.Units.gridUnit * 15 activeFocusOnTab: true //background Rectangle { anchors.fill: parent color: Kube.Colors.backgroundColor Rectangle { height: parent.height width: 1 color: Kube.Colors.buttonColor } } //Content ColumnLayout { anchors { fill: parent margins: Kube.Units.largeSpacing } spacing: Kube.Units.largeSpacing ColumnLayout { Layout.maximumWidth: parent.width Layout.fillWidth: true Layout.fillHeight: true Kube.Label { text: qsTr("Sending Email to:") } AddresseeListEditor { Layout.preferredHeight: implicitHeight Layout.fillWidth: true focus: true activeFocusOnTab: true encrypt: composerController.encrypt controller: composerController.to completer: composerController.recipientCompleter } Kube.Label { text: qsTr("Sending Copy to (CC):") } AddresseeListEditor { id: cc Layout.preferredHeight: cc.implicitHeight Layout.fillWidth: true activeFocusOnTab: true encrypt: composerController.encrypt controller: composerController.cc completer: composerController.recipientCompleter } Kube.Label { text: qsTr("Sending Secret Copy to (Bcc):") } AddresseeListEditor { id: bcc Layout.preferredHeight: bcc.implicitHeight Layout.fillWidth: true activeFocusOnTab: true encrypt: composerController.encrypt controller: composerController.bcc completer: composerController.recipientCompleter } Item { width: parent.width Layout.fillHeight: true } } RowLayout { enabled: composerController.foundPersonalKeys Kube.CheckBox { id: encryptCheckbox checked: false } Kube.Label { text: qsTr("encrypt") } } RowLayout { enabled: composerController.foundPersonalKeys Kube.CheckBox { id: signCheckbox checked: false } Kube.Label { text: qsTr("sign") } } Kube.Label { visible: !composerController.foundPersonalKeys Layout.maximumWidth: parent.width text: qsTr("Encryption is not available because your personal key has not been found.") wrapMode: Text.Wrap } RowLayout { Layout.maximumWidth: parent.width width: parent.width height: Kube.Units.gridUnit Kube.Button { width: saveDraftButton.width text: qsTr("Discard") onClicked: root.done() } Kube.Button { id: saveDraftButton text: qsTr("Save as Draft") enabled: composerController.saveAsDraftAction.enabled onClicked: { composerController.saveAsDraftAction.execute() } } } ColumnLayout { Layout.maximumWidth: parent.width Layout.fillWidth: true Kube.Label { id: fromLabel text: qsTr("You are sending this from:") } Kube.ComboBox { id: identityCombo width: parent.width - Kube.Units.largeSpacing * 2 model: composerController.identitySelector.model textRole: "address" Layout.fillWidth: true + currentIndex: composerController.identitySelector.currentIndex onCurrentIndexChanged: { composerController.identitySelector.currentIndex = currentIndex } } } Kube.PositiveButton { objectName: "sendButton" id: sendButton width: parent.width text: qsTr("Send") enabled: composerController.sendAction.enabled onClicked: { composerController.sendAction.execute() } } } }//FocusScope }