diff --git a/interfaces/conversationmessage.h b/interfaces/conversationmessage.h index 7c60141b..316b1d9c 100644 --- a/interfaces/conversationmessage.h +++ b/interfaces/conversationmessage.h @@ -1,126 +1,128 @@ /** * Copyright 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ #ifndef PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ #define PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ #include #include "kdeconnectinterfaces_export.h" class KDECONNECTINTERFACES_EXPORT ConversationMessage { public: // TYPE field values from Android enum Types { MessageTypeAll = 0, MessageTypeInbox = 1, MessageTypeSent = 2, MessageTypeDraft = 3, MessageTypeOutbox = 4, MessageTypeFailed = 5, MessageTypeQueued = 6, }; /** * Values describing the possible type of events contained in a message * A message's eventField is constructed as a bitwise-OR of events * Any events which are unsupported should be ignored */ enum Events { EventTextMessage = 0x1, // This message has a body field which contains pure, human-readable text + EventMultiTarget = 0x2, // This is a multitarget (group) message which has an "addresses" field which is a list of participants in the group }; /** * Build a new message from a keyword argument dictionary * * @param args mapping of field names to values as might be contained in a network packet containing a message */ ConversationMessage(const QVariantMap& args = QVariantMap()); ConversationMessage(const qint32& eventField, const QString& body, const QString& address, const qint64& date, const qint32& type, const qint32& read, const qint64& threadID, const qint32& uID); ConversationMessage(const ConversationMessage& other); ~ConversationMessage(); ConversationMessage& operator=(const ConversationMessage& other); static void registerDbusType(); qint32 eventField() const { return m_eventField; } QString body() const { return m_body; } QString address() const { return m_address; } qint64 date() const { return m_date; } qint32 type() const { return m_type; } qint32 read() const { return m_read; } qint64 threadID() const { return m_threadID; } qint32 uID() const { return m_uID; } QVariantMap toVariant() const; bool containsTextBody() const { return (eventField() & ConversationMessage::EventTextMessage); } + bool isMultitarget() const { return (eventField() & ConversationMessage::EventMultiTarget); } protected: /** * Bitwise OR of event flags * Unsupported flags shall cause the message to be ignored */ qint32 m_eventField; /** * Body of the message */ QString m_body; /** * Remote-side address of the message. Most likely a phone number, but may be an email address */ QString m_address; /** * Date stamp (Unix epoch millis) associated with the message */ qint64 m_date; /** * Type of the message. See the message.type enum */ qint32 m_type; /** * Whether we have a read report for this message */ qint32 m_read; /** * Tag which binds individual messages into a thread */ qint64 m_threadID; /** * Value which uniquely identifies a message */ qint32 m_uID; }; Q_DECLARE_METATYPE(ConversationMessage); #endif /* PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ */ diff --git a/plugins/sms/smsplugin.cpp b/plugins/sms/smsplugin.cpp index 64d528d9..e518388a 100644 --- a/plugins/sms/smsplugin.cpp +++ b/plugins/sms/smsplugin.cpp @@ -1,126 +1,126 @@ /** * Copyright 2013 Albert Vaca * Copyright 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ #include "smsplugin.h" #include #include #include #include #include #include #include #include "sendreplydialog.h" K_PLUGIN_CLASS_WITH_JSON(SmsPlugin, "kdeconnect_sms.json") Q_LOGGING_CATEGORY(KDECONNECT_PLUGIN_SMS, "kdeconnect.plugin.sms") SmsPlugin::SmsPlugin(QObject* parent, const QVariantList& args) : KdeConnectPlugin(parent, args) , m_telepathyInterface(QStringLiteral("org.freedesktop.Telepathy.ConnectionManager.kdeconnect"), QStringLiteral("/kdeconnect")) , m_conversationInterface(new ConversationsDbusInterface(this)) { } SmsPlugin::~SmsPlugin() { // m_conversationInterface is self-deleting, see ~ConversationsDbusInterface for more information } bool SmsPlugin::receivePacket(const NetworkPacket& np) { if (np.type() == PACKET_TYPE_SMS_MESSAGES) { return handleBatchMessages(np); } return true; } void SmsPlugin::sendSms(const QString& phoneNumber, const QString& messageBody) { NetworkPacket np(PACKET_TYPE_SMS_REQUEST, { {QStringLiteral("sendSms"), true}, {QStringLiteral("phoneNumber"), phoneNumber}, {QStringLiteral("messageBody"), messageBody} }); qCDebug(KDECONNECT_PLUGIN_SMS) << "Dispatching SMS send request to remote"; sendPacket(np); } void SmsPlugin::requestAllConversations() { NetworkPacket np(PACKET_TYPE_SMS_REQUEST_CONVERSATIONS); sendPacket(np); } void SmsPlugin::requestConversation (const qint64& conversationID) const { NetworkPacket np(PACKET_TYPE_SMS_REQUEST_CONVERSATION); np.set(QStringLiteral("threadID"), conversationID); sendPacket(np); } void SmsPlugin::forwardToTelepathy(const ConversationMessage& message) { // If we don't have a valid Telepathy interface, bail out if (!(m_telepathyInterface.isValid())) return; qCDebug(KDECONNECT_PLUGIN_SMS) << "Passing a text message to the telepathy interface"; connect(&m_telepathyInterface, SIGNAL(messageReceived(QString,QString)), SLOT(sendSms(QString,QString)), Qt::UniqueConnection); const QString messageBody = message.body(); const QString contactName; // TODO: When telepathy support is improved, look up the contact with KPeople const QString phoneNumber = message.address(); m_telepathyInterface.call(QDBus::NoBlock, QStringLiteral("sendMessage"), phoneNumber, contactName, messageBody); } bool SmsPlugin::handleBatchMessages(const NetworkPacket& np) { const auto messages = np.get(QStringLiteral("messages")); QList messagesList; messagesList.reserve(messages.count()); for (const QVariant& body : messages) { ConversationMessage message(body.toMap()); if (message.containsTextBody()) { forwardToTelepathy(message); - messagesList.append(message); } + messagesList.append(message); } m_conversationInterface->addMessages(messagesList); return true; } QString SmsPlugin::dbusPath() const { return QStringLiteral("/modules/kdeconnect/devices/") + device()->id() + QStringLiteral("/sms"); } #include "smsplugin.moc" diff --git a/smsapp/conversationlistmodel.cpp b/smsapp/conversationlistmodel.cpp index 364dc397..6cf82879 100644 --- a/smsapp/conversationlistmodel.cpp +++ b/smsapp/conversationlistmodel.cpp @@ -1,211 +1,224 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ #include "conversationlistmodel.h" #include #include + +#include + #include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" #include "smshelper.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL, "kdeconnect.sms.conversations_list") OurSortFilterProxyModel::OurSortFilterProxyModel(){} OurSortFilterProxyModel::~OurSortFilterProxyModel(){} ConversationListModel::ConversationListModel(QObject* parent) : QStandardItemModel(parent) , m_conversationsInterface(nullptr) { //qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Constructing" << this; auto roles = roleNames(); roles.insert(FromMeRole, "fromMe"); roles.insert(AddressRole, "address"); roles.insert(PersonUriRole, "personUri"); roles.insert(ConversationIdRole, "conversationId"); + roles.insert(MultitargetRole, "isMultitarget"); roles.insert(DateRole, "date"); setItemRoleNames(roles); ConversationMessage::registerDbusType(); } ConversationListModel::~ConversationListModel() { } void ConversationListModel::setDeviceId(const QString& deviceId) { if (deviceId == m_deviceId) { return; } if (deviceId.isEmpty()) { return; } qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "setDeviceId" << deviceId << "of" << this; if (m_conversationsInterface) { disconnect(m_conversationsInterface, SIGNAL(conversationCreated(QVariantMap)), this, SLOT(handleCreatedConversation(QVariantMap))); disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdated(QVariantMap))); delete m_conversationsInterface; m_conversationsInterface = nullptr; } // This method still gets called *with a valid deviceID* when the device is not connected while the component is setting up // Detect that case and don't do anything. DeviceDbusInterface device(deviceId); if (!(device.isValid() && device.isReachable())) { return; } m_deviceId = deviceId; Q_EMIT deviceIdChanged(); m_conversationsInterface = new DeviceConversationsDbusInterface(deviceId, this); connect(m_conversationsInterface, SIGNAL(conversationCreated(QVariantMap)), this, SLOT(handleCreatedConversation(QVariantMap))); connect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdated(QVariantMap))); prepareConversationsList(); m_conversationsInterface->requestAllConversationThreads(); } void ConversationListModel::prepareConversationsList() { if (!m_conversationsInterface->isValid()) { qCWarning(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Tried to prepareConversationsList with an invalid interface!"; return; } QDBusPendingReply validThreadIDsReply = m_conversationsInterface->activeConversations(); setWhenAvailable(validThreadIDsReply, [this](const QVariantList& convs) { clear(); // If we clear before we receive the reply, there might be a (several second) visual gap! for (const QVariant& headMessage : convs) { QDBusArgument data = headMessage.value(); QVariantMap message; data >> message; handleCreatedConversation(message); } }, this); } void ConversationListModel::handleCreatedConversation(const QVariantMap& msg) { createRowFromMessage(msg); } void ConversationListModel::handleConversationUpdated(const QVariantMap& msg) { createRowFromMessage(msg); } void ConversationListModel::printDBusError(const QDBusError& error) { qCWarning(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << error; } QStandardItem * ConversationListModel::conversationForThreadId(qint32 threadId) { for(int i=0, c=rowCount(); idata(ConversationIdRole) == threadId) return it; } return nullptr; } void ConversationListModel::createRowFromMessage(const QVariantMap& msg) { const ConversationMessage message(msg); if (message.type() == -1) { // The Android side currently hacks in -1 if something weird comes up // TODO: Remove this hack when MMS support is implemented return; } bool toadd = false; QStandardItem* item = conversationForThreadId(message.threadID()); if (!item) { toadd = true; item = new QStandardItem(); QScopedPointer personData(lookupPersonByAddress(message.address())); if (personData) { item->setText(personData->name()); item->setIcon(QIcon(personData->photo())); item->setData(personData->personUri(), PersonUriRole); } else { item->setData(QString(), PersonUriRole); item->setText(message.address()); } item->setData(message.threadID(), ConversationIdRole); } + // TODO: Upgrade to support other kinds of media + // Get the body that we should display + QString displayBody = message.containsTextBody() ? message.body() : i18n("(Unsupported Message Type)"); + + // TODO: Upgrade with multitarget support + if (message.isMultitarget()) { + item->setText(i18n("(Multitarget Message)")); + } // Update the message if the data is newer // This will be true if a conversation receives a new message, but false when the user // does something to trigger past conversation history loading bool oldDateExists; qint64 oldDate = item->data(DateRole).toLongLong(&oldDateExists); if (!oldDateExists || message.date() >= oldDate) { // If there was no old data or incoming data is newer, update the record item->setData(message.address(), AddressRole); item->setData(message.type() == ConversationMessage::MessageTypeSent, FromMeRole); - item->setData(message.body(), Qt::ToolTipRole); + item->setData(displayBody, Qt::ToolTipRole); item->setData(message.date(), DateRole); + item->setData(message.isMultitarget(), MultitargetRole); } if (toadd) appendRow(item); } KPeople::PersonData* ConversationListModel::lookupPersonByAddress(const QString& address) { const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address); int rowIndex = 0; for (rowIndex = 0; rowIndex < m_people.rowCount(); rowIndex++) { const QString& uri = m_people.get(rowIndex, KPeople::PersonsModel::PersonUriRole).toString(); KPeople::PersonData* person = new KPeople::PersonData(uri); const QStringList& allEmails = person->allEmails(); for (const QString& email : allEmails) { // Although we are nominally an SMS messaging app, it is possible to send messages to phone numbers using email -> sms bridges if (address == email) { return person; } } // TODO: Either upgrade KPeople with an allPhoneNumbers method const QVariantList allPhoneNumbers = person->contactCustomProperty(QStringLiteral("all-phoneNumber")).toList(); for (const QVariant& rawPhoneNumber : allPhoneNumbers) { const QString& phoneNumber = SmsHelper::canonicalizePhoneNumber(rawPhoneNumber.toString()); bool matchingPhoneNumber = SmsHelper::isPhoneNumberMatchCanonicalized(canonicalAddress, phoneNumber); if (matchingPhoneNumber) { //qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Matched" << address << "to" << person->name(); return person; } } delete person; } return nullptr; } diff --git a/smsapp/conversationlistmodel.h b/smsapp/conversationlistmodel.h index 9c34cc8a..77595b72 100644 --- a/smsapp/conversationlistmodel.h +++ b/smsapp/conversationlistmodel.h @@ -1,119 +1,120 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ #ifndef CONVERSATIONLISTMODEL_H #define CONVERSATIONLISTMODEL_H #include #include #include #include #include #include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) class OurSortFilterProxyModel : public QSortFilterProxyModel, public QQmlParserStatus { Q_OBJECT Q_INTERFACES(QQmlParserStatus) Q_PROPERTY(Qt::SortOrder sortOrder READ sortOrder WRITE setSortOrder) public: Qt::SortOrder sortOrder() const { return m_sortOrder; } void setSortOrder(Qt::SortOrder sortOrder) { if (m_sortOrder != sortOrder) { m_sortOrder = sortOrder; sortNow(); } } void classBegin() override {} void componentComplete() override { m_completed = true; sortNow(); } OurSortFilterProxyModel(); ~OurSortFilterProxyModel(); private: void sortNow() { if (m_completed && dynamicSortFilter()) sort(0, m_sortOrder); } bool m_completed = false; Qt::SortOrder m_sortOrder = Qt::AscendingOrder; }; class ConversationListModel : public QStandardItemModel { Q_OBJECT Q_PROPERTY(QString deviceId READ deviceId WRITE setDeviceId NOTIFY deviceIdChanged) public: ConversationListModel(QObject* parent = nullptr); ~ConversationListModel(); enum Roles { FromMeRole = Qt::UserRole, PersonUriRole, AddressRole, ConversationIdRole, DateRole, + MultitargetRole, // Indicate that this conversation is multitarget }; Q_ENUM(Roles) QString deviceId() const { return m_deviceId; } void setDeviceId(const QString &/*deviceId*/); public Q_SLOTS: void handleCreatedConversation(const QVariantMap& msg); void handleConversationUpdated(const QVariantMap& msg); void createRowFromMessage(const QVariantMap& message); void printDBusError(const QDBusError& error); Q_SIGNALS: void deviceIdChanged(); private: /** * Get all conversations currently known by the conversationsInterface, if any */ void prepareConversationsList(); /** * Get the data for a particular person given their contact address */ KPeople::PersonData* lookupPersonByAddress(const QString& address); QStandardItem* conversationForThreadId(qint32 threadId); DeviceConversationsDbusInterface* m_conversationsInterface; QString m_deviceId; KPeople::PersonsModel m_people; }; #endif // CONVERSATIONLISTMODEL_H diff --git a/smsapp/conversationmodel.cpp b/smsapp/conversationmodel.cpp index 0378182f..99e0c45b 100644 --- a/smsapp/conversationmodel.cpp +++ b/smsapp/conversationmodel.cpp @@ -1,134 +1,141 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ #include "conversationmodel.h" #include + +#include + #include "interfaces/conversationmessage.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATION_MODEL, "kdeconnect.sms.conversation") ConversationModel::ConversationModel(QObject* parent) : QStandardItemModel(parent) , m_conversationsInterface(nullptr) { auto roles = roleNames(); roles.insert(FromMeRole, "fromMe"); roles.insert(DateRole, "date"); setItemRoleNames(roles); } ConversationModel::~ConversationModel() { } qint64 ConversationModel::threadId() const { return m_threadId; } void ConversationModel::setThreadId(const qint64& threadId) { if (m_threadId == threadId) return; m_threadId = threadId; clear(); knownMessageIDs.clear(); if (m_threadId != INVALID_THREAD_ID && !m_deviceId.isEmpty()) { requestMoreMessages(); } } void ConversationModel::setDeviceId(const QString& deviceId) { if (deviceId == m_deviceId) return; qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "setDeviceId" << "of" << this; if (m_conversationsInterface) { disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdate(QVariantMap))); delete m_conversationsInterface; } m_deviceId = deviceId; m_conversationsInterface = new DeviceConversationsDbusInterface(deviceId, this); connect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdate(QVariantMap))); } void ConversationModel::sendReplyToConversation(const QString& message) { //qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Trying to send" << message << "to conversation with ID" << m_threadId; m_conversationsInterface->replyToConversation(m_threadId, message); } void ConversationModel::requestMoreMessages(const quint32& howMany) { if (m_threadId == INVALID_THREAD_ID) { return; } const auto& numMessages = rowCount(); m_conversationsInterface->requestConversation(m_threadId, numMessages, numMessages + howMany); } void ConversationModel::createRowFromMessage(const QVariantMap& msg, int pos) { const ConversationMessage message(msg); if (message.threadID() != m_threadId) { // Because of the asynchronous nature of the current implementation of this model, if the // user clicks quickly between threads or for some other reason a message comes when we're // not expecting it, we should not display it in the wrong place qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Got a message for a thread" << message.threadID() << "but we are currently viewing" << m_threadId << "Discarding."; return; } if (knownMessageIDs.contains(message.uID())) { qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Ignoring duplicate message with ID" << message.uID(); return; } + // TODO: Upgrade to support other kinds of media + // Get the body that we should display + QString displayBody = message.containsTextBody() ? message.body() : i18n("(Unsupported Message Type)"); + auto item = new QStandardItem; - item->setText(message.body()); + item->setText(displayBody); item->setData(message.type() == ConversationMessage::MessageTypeSent, FromMeRole); item->setData(message.date(), DateRole); insertRow(pos, item); knownMessageIDs.insert(message.uID()); } void ConversationModel::handleConversationUpdate(const QVariantMap& msg) { const ConversationMessage message(msg); if (message.threadID() != m_threadId) { // If a conversation which we are not currently viewing was updated, discard the information qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Saw update for thread" << message.threadID() << "but we are currently viewing" << m_threadId; return; } createRowFromMessage(msg, 0); } diff --git a/smsapp/qml/ConversationDisplay.qml b/smsapp/qml/ConversationDisplay.qml index e00e17cd..08623658 100644 --- a/smsapp/qml/ConversationDisplay.qml +++ b/smsapp/qml/ConversationDisplay.qml @@ -1,203 +1,204 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Nicolas Fella * Copyright (C) 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ import QtQuick 2.1 import QtQuick.Controls 2.2 import QtQuick.Layouts 1.1 import org.kde.people 1.0 import org.kde.kirigami 2.4 as Kirigami import org.kde.kdeconnect.sms 1.0 import QtGraphicalEffects 1.0 Kirigami.ScrollablePage { id: page property alias personUri: person.personUri readonly property QtObject person: PersonData { id: person } property bool deviceConnected property string deviceId // property QtObject device property string conversationId + property bool isMultitarget property string initialMessage property string phoneNumber title: person.person && person.person.name ? person.person.name : phoneNumber Component.onCompleted: { if (initialMessage.length > 0) { messageField.text = initialMessage; initialMessage = "" } } /** * Build a chat message which is representative of all chat messages * * In other words, one which I can use to get a reasonable height guess */ ChatMessage { id: genericMessage messageBody: "Generic Message Body" dateTime: new Date('2000-0-0') visible: false enabled: false } ListView { id: viewport model: QSortFilterProxyModel { id: model sortOrder: Qt.AscendingOrder sortRole: ConversationModel.DateRole sourceModel: ConversationModel { deviceId: page.deviceId threadId: page.conversationId } } spacing: Kirigami.Units.largeSpacing delegate: ChatMessage { messageBody: model.display sentByMe: model.fromMe dateTime: new Date(model.date) ListView.onAdd: { if (index == viewport.count - 1) // This message is being inserted at the newest position // We want to scroll to show it if the user is "almost" looking at it // Define some fudge area. If the message is being drawn offscreen but within // this distance, we move to show it anyway. // Selected to be genericMessage.height because that value scales for different // font sizes / DPI / etc. -- Better ideas are welcome! // Double the value works nicely var offscreenFudge = 2 * genericMessage.height var viewportYBottom = viewport.contentY + viewport.height if (y < viewportYBottom + genericMessage.height) { viewport.currentIndex = index } } } onMovementEnded: { // Unset the highlightRangeMode if it was set previously highlightRangeMode = ListView.ApplyRange highlightMoveDuration: -1 // "Re-enable" the highlight animation if (atYBeginning) { // "Lock" the view to the message currently at the beginning of the view // This prevents the view from snapping to the top of the messages we are about to request currentIndex = 0 // Index 0 is the beginning of the view preferredHighlightBegin = visibleArea.yPosition preferredHighlightEnd = preferredHighlightBegin + currentItem.height highlightRangeMode = ListView.StrictlyEnforceRange highlightMoveDuration = 1 // This is not ideal: I would like to disable the highlight animation altogether // Get more messages model.sourceModel.requestMoreMessages() } } } footer: Pane { id: sendingArea - enabled: page.deviceConnected + enabled: page.deviceConnected && !page.isMultitarget layer.enabled: sendingArea.enabled layer.effect: DropShadow { verticalOffset: 1 color: Kirigami.Theme.disabledTextColor samples: 20 spread: 0.3 } Layout.fillWidth: true padding: 0 wheelEnabled: true background: Rectangle { color: Kirigami.Theme.viewBackgroundColor } RowLayout { anchors.fill: parent TextArea { id: messageField Layout.fillWidth: true - placeholderText: i18n("Compose message") + placeholderText: page.isMultitarget ? i18n("Replying to multitarget messages is not supported") : i18n("Compose message") wrapMode: TextArea.Wrap topPadding: Kirigami.Units.gridUnit * 0.5 bottomPadding: topPadding selectByMouse: true background: Item {} Keys.onReturnPressed: { if (event.key === Qt.Key_Return) { if (event.modifiers & Qt.ShiftModifier) { messageField.append("") } else { sendButton.onClicked() event.accepted = true } } } } ToolButton { id: sendButton Layout.preferredWidth: Kirigami.Units.gridUnit * 2 Layout.preferredHeight: Kirigami.Units.gridUnit * 2 padding: 0 Kirigami.Icon { source: "document-send" enabled: sendButton.enabled isMask: true smooth: true anchors.centerIn: parent width: Kirigami.Units.gridUnit * 1.5 height: width } onClicked: { // don't send empty messages if (!messageField.text.length) { return } // disable the button to prevent sending // the same message several times sendButton.enabled = false // send the message model.sourceModel.sendReplyToConversation(messageField.text) messageField.text = "" // re-enable the button sendButton.enabled = true } } } } } diff --git a/smsapp/qml/ConversationList.qml b/smsapp/qml/ConversationList.qml index ab941375..b5598ed9 100644 --- a/smsapp/qml/ConversationList.qml +++ b/smsapp/qml/ConversationList.qml @@ -1,169 +1,170 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Nicolas Fella * Copyright (C) 2018 Simon Redman * * 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) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * 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, see . */ import QtQuick 2.5 import QtQuick.Controls 2.1 import QtQuick.Layouts 1.1 import org.kde.people 1.0 import org.kde.kirigami 2.4 as Kirigami import org.kde.kdeconnect 1.0 import org.kde.kdeconnect.sms 1.0 Kirigami.ScrollablePage { id: page ToolTip { id: noDevicesWarning visible: !devicesCombo.enabled timeout: -1 text: "⚠️ " + i18n("No devices available") + " ⚠️" MouseArea { // Detect mouseover and show another tooltip with more information anchors.fill: parent hoverEnabled: true ToolTip.visible: containsMouse ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval // TODO: Wrap text if line is too long for the screen ToolTip.text: i18n("No new messages can be sent or received, but you can browse cached content") } } property string initialMessage header: Kirigami.InlineMessage { Layout.fillWidth: true visible: page.initialMessage.length > 0 text: i18n("Choose recipient") actions: [ Kirigami.Action { iconName: "dialog-cancel" text: "Cancel" onTriggered: initialMessage = "" } ] } footer: ComboBox { id: devicesCombo enabled: count > 0 displayText: !enabled ? i18n("No devices available") : undefined model: DevicesSortProxyModel { id: devicesModel //TODO: make it possible to filter if they can do sms sourceModel: DevicesModel { displayFilter: DevicesModel.Paired | DevicesModel.Reachable } onRowsInserted: if (devicesCombo.currentIndex < 0) { devicesCombo.currentIndex = 0 } } textRole: "display" } readonly property bool deviceConnected: devicesCombo.enabled readonly property QtObject device: devicesCombo.currentIndex >= 0 ? devicesModel.data(devicesModel.index(devicesCombo.currentIndex, 0), DevicesModel.DeviceRole) : null readonly property alias lastDeviceId: conversationListModel.deviceId Component { id: chatView ConversationDisplay { deviceId: page.lastDeviceId deviceConnected: page.deviceConnected } } ListView { id: view currentIndex: 0 model: QSortFilterProxyModel { sortOrder: Qt.DescendingOrder sortRole: ConversationListModel.DateRole filterCaseSensitivity: Qt.CaseInsensitive sourceModel: ConversationListModel { id: conversationListModel deviceId: device ? device.id() : "" } } header: TextField { /** * Used as the filter of the list of messages */ id: filter placeholderText: i18n("Filter...") width: parent.width z: 10 onTextChanged: { view.model.setFilterFixedString(filter.text); view.currentIndex = 0 } onAccepted: { view.currentItem.startChat() } Keys.onReturnPressed: { event.accepted = true filter.onAccepted() } Keys.onEscapePressed: { event.accepted = filter.text != "" filter.text = "" } Shortcut { sequence: "Ctrl+F" onActivated: filter.forceActiveFocus() } } headerPositioning: ListView.OverlayHeader Keys.forwardTo: [headerItem] delegate: Kirigami.BasicListItem { hoverEnabled: true label: i18n("%1
%2", display, toolTip) icon: decoration function startChat() { applicationWindow().pageStack.push(chatView, { personUri: model.personUri, phoneNumber: address, conversationId: model.conversationId, + isMultitarget: isMultitarget, initialMessage: page.initialMessage, device: device}) initialMessage = "" } onClicked: { startChat(); view.currentIndex = index } // Keep the currently-open chat highlighted even if this element is not focused highlighted: chatView.conversationId == model.conversationId } Component.onCompleted: { currentIndex = -1 focus = true } } }