From e64a9e7ee79d843d927dea7f6879b03737587dab Mon Sep 17 00:00:00 2001 From: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> Date: Thu, 15 Apr 2021 23:17:58 -0400 Subject: [PATCH] sidepanel: improve smartlist interface with underlying models Minor cosmetic changes to the account combo box, search bar, filter tabs, and smartlist. Change-Id: Ie8173504859b325374e42f0dbb4e0ae75f3ed740 Gitlab: #373 Gitlab: #374 Gitlab: #388 --- CMakeLists.txt | 12 +- qml.qrc | 5 +- src/accountadapter.cpp | 80 ++--- src/accountadapter.h | 10 +- src/calladapter.cpp | 40 ++- src/calladapter.h | 2 - src/commoncomponents/AvatarImage.qml | 32 +- src/commoncomponents/BaseContextMenu.qml | 18 + src/commoncomponents/ModalPopup.qml | 2 +- src/commoncomponents/PresenceIndicator.qml | 2 +- src/commoncomponents/Scaffold.qml | 35 +- src/constant/JamiStrings.qml | 5 +- src/constant/JamiTheme.qml | 10 +- src/contactadapter.cpp | 29 +- src/contactadapter.h | 33 +- src/conversationlistmodel.cpp | 131 +++++++ src/conversationlistmodel.h | 55 +++ src/conversationlistmodelbase.cpp | 201 +++++++++++ src/conversationlistmodelbase.h | 88 +++++ src/conversationsadapter.cpp | 284 ++++++++++----- src/conversationsadapter.h | 33 +- src/lrcinstance.cpp | 71 ++-- src/lrcinstance.h | 13 +- src/mainapplication.h | 3 + src/mainview/MainView.qml | 81 ++--- src/mainview/components/AccountComboBox.qml | 15 +- .../components/AccountComboBoxPopup.qml | 8 +- src/mainview/components/BadgeNotifier.qml | 79 +++++ src/mainview/components/CallOverlay.qml | 4 +- src/mainview/components/ContactPicker.qml | 14 +- .../components/ContactPickerItemDelegate.qml | 4 +- src/mainview/components/ContactSearchBar.qml | 9 +- .../components/ConversationListView.qml | 222 ++++++++++++ .../ConversationSmartListContextMenu.qml | 12 +- .../components/ConversationSmartListView.qml | 171 --------- .../ConversationSmartListViewItemDelegate.qml | 269 -------------- src/mainview/components/FilterTabButton.qml | 36 +- src/mainview/components/MessageWebView.qml | 8 + src/mainview/components/SidePanel.qml | 204 +++++------ src/mainview/components/SidePanelTabBar.qml | 36 +- .../components/SmartListItemDelegate.qml | 184 ++++++++++ src/messagesadapter.cpp | 10 +- src/messagesadapter.h | 1 - src/qmlregister.cpp | 5 + src/qmlregister.h | 7 +- src/searchresultslistmodel.cpp | 57 +++ src/searchresultslistmodel.h | 49 +++ src/selectablelistproxymodel.cpp | 167 +++++++++ src/selectablelistproxymodel.h | 66 ++++ .../components/AdvancedCallSettings.qml | 2 +- .../components/CurrentAccountSettings.qml | 2 +- src/smartlistmodel.cpp | 328 ++++-------------- src/smartlistmodel.h | 67 +--- src/utils.cpp | 6 +- src/utilsadapter.cpp | 23 -- src/utilsadapter.h | 2 - 56 files changed, 1997 insertions(+), 1345 deletions(-) create mode 100644 src/conversationlistmodel.cpp create mode 100644 src/conversationlistmodel.h create mode 100644 src/conversationlistmodelbase.cpp create mode 100644 src/conversationlistmodelbase.h create mode 100644 src/mainview/components/BadgeNotifier.qml create mode 100644 src/mainview/components/ConversationListView.qml delete mode 100644 src/mainview/components/ConversationSmartListView.qml delete mode 100644 src/mainview/components/ConversationSmartListViewItemDelegate.qml create mode 100644 src/mainview/components/SmartListItemDelegate.qml create mode 100644 src/searchresultslistmodel.cpp create mode 100644 src/searchresultslistmodel.h create mode 100644 src/selectablelistproxymodel.cpp create mode 100644 src/selectablelistproxymodel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 77dc4ea31..a678b4eb5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,7 +67,11 @@ set(COMMON_SOURCES ${SRC_DIR}/screensaver.cpp ${SRC_DIR}/systemtray.cpp ${SRC_DIR}/appsettingsmanager.cpp - ${SRC_DIR}/lrcinstance.cpp) + ${SRC_DIR}/lrcinstance.cpp + ${SRC_DIR}/selectablelistproxymodel.cpp + ${SRC_DIR}/conversationlistmodelbase.cpp + ${SRC_DIR}/conversationlistmodel.cpp + ${SRC_DIR}/searchresultslistmodel.cpp) set(COMMON_HEADERS ${SRC_DIR}/avatarimageprovider.h @@ -118,7 +122,11 @@ set(COMMON_HEADERS ${SRC_DIR}/screensaver.h ${SRC_DIR}/systemtray.h ${SRC_DIR}/appsettingsmanager.h - ${SRC_DIR}/lrcinstance.h) + ${SRC_DIR}/lrcinstance.h + ${SRC_DIR}/selectablelistproxymodel.h + ${SRC_DIR}/conversationlistmodelbase.h + ${SRC_DIR}/conversationlistmodel.h + ${SRC_DIR}/searchresultslistmodel.h) set(QML_LIBS Qt5::Quick diff --git a/qml.qrc b/qml.qrc index b2782a70a..15cbfbd12 100644 --- a/qml.qrc +++ b/qml.qrc @@ -102,7 +102,6 @@ <file>src/mainview/components/MessageWebView.qml</file> <file>src/mainview/components/MessageWebViewHeader.qml</file> <file>src/mainview/components/AccountComboBox.qml</file> - <file>src/mainview/components/ConversationSmartListView.qml</file> <file>src/mainview/components/CallStackView.qml</file> <file>src/mainview/components/IncomingCallPage.qml</file> <file>src/mainview/components/OutgoingCallPage.qml</file> @@ -114,7 +113,6 @@ <file>src/mainview/components/ParticipantOverlay.qml</file> <file>src/mainview/components/ProjectCreditsScrollView.qml</file> <file>src/mainview/components/AccountComboBoxPopup.qml</file> - <file>src/mainview/components/ConversationSmartListViewItemDelegate.qml</file> <file>src/mainview/components/SidePanelTabBar.qml</file> <file>src/mainview/components/WelcomePageQrDialog.qml</file> <file>src/mainview/components/ConversationSmartListContextMenu.qml</file> @@ -138,5 +136,8 @@ <file>src/mainview/js/pluginhandlerpickercreation.js</file> <file>src/mainview/components/FilterTabButton.qml</file> <file>src/mainview/components/AccountItemDelegate.qml</file> + <file>src/mainview/components/ConversationListView.qml</file> + <file>src/mainview/components/SmartListItemDelegate.qml</file> + <file>src/mainview/components/BadgeNotifier.qml</file> </qresource> </RCC> diff --git a/src/accountadapter.cpp b/src/accountadapter.cpp index eefb8c6bc..e29cc2550 100644 --- a/src/accountadapter.cpp +++ b/src/accountadapter.cpp @@ -43,8 +43,6 @@ AccountAdapter::safeInit() this, &AccountAdapter::onCurrentAccountChanged); - deselectConversation(); - auto accountId = lrcInstance_->getCurrAccId(); setProperties(accountId); connectAccount(accountId); @@ -65,7 +63,6 @@ AccountAdapter::getDeviceModel() void AccountAdapter::changeAccount(int row) { - deselectConversation(); // Hack UI auto accountList = lrcInstance_->accountModel().getAccountList(); if (accountList.size() > row) { lrcInstance_->setSelectedAccountId(accountList.at(row)); @@ -269,12 +266,6 @@ AccountAdapter::setCurrAccDisplayName(const QString& text) lrcInstance_->setCurrAccDisplayName(text); } -void -AccountAdapter::setSelectedConvId(const QString& convId) -{ - lrcInstance_->set_selectedConvUid(convId); -} - lrc::api::profile::Type AccountAdapter::getCurrentAccountType() { @@ -347,23 +338,6 @@ AccountAdapter::passwordSetStatusMessageBox(bool success, QString title, QString } } -void -AccountAdapter::deselectConversation() -{ - if (lrcInstance_->get_selectedConvUid().isEmpty()) { - return; - } - - // TODO: remove this unhealthy section - auto currentConversationModel = lrcInstance_->getCurrentConversationModel(); - - if (currentConversationModel == nullptr) { - return; - } - - lrcInstance_->set_selectedConvUid(); -} - void AccountAdapter::connectAccount(const QString& accountId) { @@ -374,7 +348,7 @@ AccountAdapter::connectAccount(const QString& accountId) QObject::disconnect(accountProfileUpdatedConnection_); QObject::disconnect(contactAddedConnection_); QObject::disconnect(addedToConferenceConnection_); - QObject::disconnect(contactUnbannedConnection_); + QObject::disconnect(bannedStatusChangedConnection_); accountStatusChangedConnection_ = QObject::connect(accInfo.accountModel, @@ -390,27 +364,22 @@ AccountAdapter::connectAccount(const QString& accountId) Q_EMIT accountStatusChanged(accountId); }); - contactAddedConnection_ - = QObject::connect(accInfo.contactModel.get(), - &lrc::api::ContactModel::contactAdded, - [this, accountId](const QString& contactUri) { - const auto& convInfo = lrcInstance_->getConversationFromConvUid( - lrcInstance_->get_selectedConvUid()); - if (convInfo.uid.isEmpty()) { - return; - } - auto& accInfo = lrcInstance_->accountModel().getAccountInfo( - accountId); - if (contactUri - == accInfo.contactModel - ->getContact(convInfo.participants.at(0)) - .profileInfo.uri) { - /* - * Update conversation. - */ - Q_EMIT updateConversationForAddedContact(); - } - }); + contactAddedConnection_ = QObject::connect( + accInfo.contactModel.get(), + &lrc::api::ContactModel::contactAdded, + [this, accountId](const QString& contactUri) { + const auto& convInfo = lrcInstance_->getConversationFromConvUid( + lrcInstance_->get_selectedConvUid()); + if (convInfo.uid.isEmpty()) { + return; + } + auto& accInfo = lrcInstance_->accountModel().getAccountInfo(accountId); + auto selectedContactUri + = accInfo.contactModel->getContact(convInfo.participants.at(0)).profileInfo.uri; + if (contactUri == selectedContactUri) { + Q_EMIT selectedContactAdded(convInfo.uid); + } + }); addedToConferenceConnection_ = QObject::connect(accInfo.callModel.get(), @@ -420,12 +389,15 @@ AccountAdapter::connectAccount(const QString& accountId) lrcInstance_->renderer()->addDistantRenderer(confId); }); - contactUnbannedConnection_ = QObject::connect(accInfo.contactModel.get(), - &lrc::api::ContactModel::bannedStatusChanged, - [this](const QString&, bool banned) { - if (!banned) - Q_EMIT contactUnbanned(); - }); + bannedStatusChangedConnection_ + = QObject::connect(accInfo.contactModel.get(), + &lrc::api::ContactModel::bannedStatusChanged, + [this](const QString& uri, bool banned) { + if (!banned) + Q_EMIT contactUnbanned(); + else + Q_EMIT lrcInstance_->contactBanned(uri); + }); } catch (...) { qWarning() << "Couldn't get account: " << accountId; } diff --git a/src/accountadapter.h b/src/accountadapter.h index 5e11d0509..985781ab3 100644 --- a/src/accountadapter.h +++ b/src/accountadapter.h @@ -95,16 +95,14 @@ public: Q_INVOKABLE bool hasVideoCall(); Q_INVOKABLE bool isPreviewing(); Q_INVOKABLE void setCurrAccDisplayName(const QString& text); - Q_INVOKABLE void setSelectedConvId(const QString& convId = {}); Q_INVOKABLE lrc::api::profile::Type getCurrentAccountType(); Q_INVOKABLE void setCurrAccAvatar(bool fromFile, const QString& source); Q_SIGNALS: // Trigger other components to reconnect account related signals. - void accountStatusChanged(QString accountId = {}); - - void updateConversationForAddedContact(); + void accountStatusChanged(QString accountId); + void selectedContactAdded(QString convId); // Send report failure to QML to make it show the right UI state . void reportFailure(); @@ -119,8 +117,6 @@ private: lrc::api::profile::Type currentAccountType_ {}; int accountListSize_ {}; - void deselectConversation(); - // Make account signal connections. void connectAccount(const QString& accountId); @@ -134,7 +130,7 @@ private: QMetaObject::Connection accountProfileUpdatedConnection_; QMetaObject::Connection contactAddedConnection_; QMetaObject::Connection addedToConferenceConnection_; - QMetaObject::Connection contactUnbannedConnection_; + QMetaObject::Connection bannedStatusChangedConnection_; QMetaObject::Connection registeredNameSavedConnection_; AppSettingsManager* settingsManager_; diff --git a/src/calladapter.cpp b/src/calladapter.cpp index 58598b72a..1be1bec54 100644 --- a/src/calladapter.cpp +++ b/src/calladapter.cpp @@ -59,9 +59,9 @@ CallAdapter::CallAdapter(SystemTray* systemTray, LRCInstance* instance, QObject* [this](const QString& accountId, const QString& convUid) { acceptACall(accountId, convUid); Q_EMIT lrcInstance_->notificationClicked(); - lrcInstance_->selectConversation(accountId, convUid); + lrcInstance_->selectConversation(convUid, accountId); updateCall(convUid, accountId); - Q_EMIT callSetupMainViewRequired(accountId, convUid); + Q_EMIT lrcInstance_->conversationUpdated(convUid, accountId); }); connect(systemTray_, &SystemTray::declineCallActivated, @@ -200,6 +200,7 @@ CallAdapter::onShowIncomingCallView(const QString& accountId, const QString& con auto selectedAccountId = lrcInstance_->getCurrAccId(); auto* callModel = lrcInstance_->getCurrentCallModel(); + // new call if (!callModel->hasCall(convInfo.callId)) { if (QApplication::focusObject() == nullptr || accountId != selectedAccountId) { showNotification(accountId, convInfo.uid); @@ -219,17 +220,21 @@ CallAdapter::onShowIncomingCallView(const QString& accountId, const QString& con return; } } - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); - Q_EMIT lrcInstance_->updateSmartList(); + // select + lrcInstance_->selectConversation(convInfo.uid, accountId); return; } + // this slot has been triggered as a result of either selecting a conversation + // with an active call, placing a call, or an incoming call for the current + // or any other conversation auto call = callModel->getCall(convInfo.callId); auto isCallSelected = lrcInstance_->get_selectedConvUid() == convInfo.uid; if (call.isOutgoing) { if (isCallSelected) { - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + // don't reselect + Q_EMIT lrcInstance_->conversationUpdated(convInfo.uid, accountId); } } else { auto accountProperties = lrcInstance_->accountModel().getAccountConfig(selectedAccountId); @@ -254,10 +259,12 @@ CallAdapter::onShowIncomingCallView(const QString& accountId, const QString& con showNotification(accountId, convInfo.uid); return; } else { - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + // only update + Q_EMIT lrcInstance_->conversationUpdated(convInfo.uid, accountId); } } else { - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + // only update + Q_EMIT lrcInstance_->conversationUpdated(convInfo.uid, accountId); } } else { // Not current conversation if (currentConvHasCall) { @@ -269,19 +276,19 @@ CallAdapter::onShowIncomingCallView(const QString& accountId, const QString& con return; } } - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + // reselect + lrcInstance_->selectConversation(convInfo.uid, accountId); } } } Q_EMIT callStatusChanged(static_cast<int>(call.status), accountId, convInfo.uid); - Q_EMIT lrcInstance_->updateSmartList(); } void CallAdapter::onShowCallView(const QString& accountId, const QString& convUid) { updateCall(convUid, accountId); - Q_EMIT callSetupMainViewRequired(accountId, convUid); + Q_EMIT lrcInstance_->conversationUpdated(convUid, accountId); } void @@ -399,8 +406,6 @@ CallAdapter::showNotification(const QString& accountId, const QString& convUid) from = accInfo.contactModel->bestNameForContact(convInfo.participants[0]); } - Q_EMIT lrcInstance_->updateSmartList(); - #ifdef Q_OS_LINUX auto contactPhoto = Utils::contactPhoto(lrcInstance_, convInfo.participants[0], @@ -414,12 +419,11 @@ CallAdapter::showNotification(const QString& accountId, const QString& convUid) Utils::QImageToByteArray(contactPhoto)); #else auto onClicked = [this, accountId, convUid = convInfo.uid]() { + Q_EMIT lrcInstance_->notificationClicked(); const auto& convInfo = lrcInstance_->getConversationFromConvUid(convUid, accountId); - if (convInfo.uid.isEmpty()) { + if (convInfo.uid.isEmpty()) return; - } - Q_EMIT lrcInstance_->notificationClicked(); - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + lrcInstance_->selectConversation(convInfo.uid, accountId); }; systemTray_->showNotification(tr("is calling you"), from, onClicked); #endif @@ -484,7 +488,7 @@ CallAdapter::connectCallModel(const QString& accountId) case lrc::api::call::Status::TIMEOUT: case lrc::api::call::Status::TERMINATING: { lrcInstance_->renderer()->removeDistantRenderer(callId); - Q_EMIT callSetupMainViewRequired(accountId, convInfo.uid); + Q_EMIT lrcInstance_->conversationUpdated(convInfo.uid, accountId); if (convInfo.uid.isEmpty()) { break; } @@ -517,7 +521,7 @@ CallAdapter::connectCallModel(const QString& accountId) /* * Reset the call view corresponding accountId, uid. */ - lrcInstance_->set_selectedConvUid(otherConv.uid); + lrcInstance_->selectConversation(otherConv.uid); updateCall(otherConv.uid, otherConv.accountId, forceCallOnly); } } diff --git a/src/calladapter.h b/src/calladapter.h index a93fafa18..efb9ddccc 100644 --- a/src/calladapter.h +++ b/src/calladapter.h @@ -80,11 +80,9 @@ public: Q_SIGNALS: void callStatusChanged(int index, const QString& accountId, const QString& convUid); - void updateConversationSmartList(); void updateParticipantsInfos(const QVariantList& infos, const QString& accountId, const QString& callId); - void callSetupMainViewRequired(const QString& accountId, const QString& convUid); void previewVisibilityNeedToChange(bool visible); // For Call Overlay diff --git a/src/commoncomponents/AvatarImage.qml b/src/commoncomponents/AvatarImage.qml index 06a0edd26..96acf5ebc 100644 --- a/src/commoncomponents/AvatarImage.qml +++ b/src/commoncomponents/AvatarImage.qml @@ -38,6 +38,7 @@ Item { property alias fillMode: rootImage.fillMode property alias sourceSize: rootImage.sourceSize + property int transitionDuration: 150 property bool saveToConfig: false property int mode: AvatarImage.Mode.FromAccount property string imageProviderIdPrefix: { @@ -178,7 +179,7 @@ Item { NumberAnimation { properties: "opacity" easing.type: Easing.InOutQuad - duration: 400 + duration: transitionDuration } } } @@ -188,38 +189,15 @@ Item { id: presenceIndicator anchors.right: root.right + anchors.rightMargin: -1 anchors.bottom: root.bottom + anchors.bottomMargin: -1 - size: root.width * 0.3 + size: root.width * 0.26 visible: showPresenceIndicator } - Rectangle { - id: unreadMessageCountRect - - anchors.right: root.right - anchors.top: root.top - - width: root.width * 0.3 - height: root.width * 0.3 - - visible: unreadMessagesCount > 0 - - Text { - id: unreadMessageCounttext - - anchors.centerIn: unreadMessageCountRect - - text: unreadMessagesCount > 9 ? "…" : unreadMessagesCount - color: "white" - font.pointSize: JamiTheme.indicatorFontSize - } - - radius: 30 - color: JamiTheme.notificationRed - } - Connections { target: ScreenInfo diff --git a/src/commoncomponents/BaseContextMenu.qml b/src/commoncomponents/BaseContextMenu.qml index da0b96397..636fc74e2 100644 --- a/src/commoncomponents/BaseContextMenu.qml +++ b/src/commoncomponents/BaseContextMenu.qml @@ -31,6 +31,12 @@ Menu { property int commonBorderWidth: 1 font.pointSize: JamiTheme.menuFontSize + modal: true + Overlay.modal: Rectangle { + color: "transparent" + } + + // TODO: investigate function openMenu(){ visible = true visible = false @@ -38,6 +44,8 @@ Menu { } background: Rectangle { + id: container + implicitWidth: menuItemsPreferredWidth implicitHeight: menuItemsPreferredHeight * (root.count - generalMenuSeparatorCount) @@ -45,5 +53,15 @@ Menu { border.width: commonBorderWidth border.color: JamiTheme.tabbarBorderColor color: JamiTheme.backgroundColor + + layer.enabled: true + layer.effect: DropShadow { + z: -1 + horizontalOffset: 3.0 + verticalOffset: 3.0 + radius: 16.0 + samples: 16 + color: JamiTheme.shadowColor + } } } diff --git a/src/commoncomponents/ModalPopup.qml b/src/commoncomponents/ModalPopup.qml index 95a3ae48d..83e7d8201 100644 --- a/src/commoncomponents/ModalPopup.qml +++ b/src/commoncomponents/ModalPopup.qml @@ -67,7 +67,7 @@ Popup { height: root.height horizontalOffset: 3.0 verticalOffset: 3.0 - radius: container.radius * 2 + radius: container.radius * 4 samples: 16 color: JamiTheme.shadowColor source: container diff --git a/src/commoncomponents/PresenceIndicator.qml b/src/commoncomponents/PresenceIndicator.qml index d7baf7692..5ee5825ab 100644 --- a/src/commoncomponents/PresenceIndicator.qml +++ b/src/commoncomponents/PresenceIndicator.qml @@ -30,7 +30,7 @@ Rectangle { // This is set to REGISTERED for contact presence // as status is not currently tracked for contact items. property int status: Account.Status.REGISTERED - property int size: 12 + property int size: 15 width: size height: size diff --git a/src/commoncomponents/Scaffold.qml b/src/commoncomponents/Scaffold.qml index 9acb5a099..4e76288c7 100644 --- a/src/commoncomponents/Scaffold.qml +++ b/src/commoncomponents/Scaffold.qml @@ -23,6 +23,12 @@ import QtQuick.Controls 2.12 Rectangle { property alias name: label.text property bool stretchParent: false + property string tag: this.toString() + signal moveX(real dx) + signal moveY(real dy) + property real ox: 0 + property real oy: 0 + property real step: 0.5 border.width: 1 color: { @@ -33,6 +39,19 @@ Rectangle { } anchors.fill: parent focus: false + Keys.onPressed: { + if (event.key === Qt.Key_Left) + moveX(-step) + else if (event.key === Qt.Key_Right) + moveX(step) + else if (event.key === Qt.Key_Down) + moveY(step) + else if (event.key === Qt.Key_Up) + moveY(-step) + console.log(tag, ox, oy) + event.accepted = true; + } + Component.onCompleted: { // fallback to some description of the object if (label.text === "") @@ -45,10 +64,24 @@ Rectangle { } } + onMoveX: { + parent.anchors.leftMargin += dx + parent.x += dx + ox += dx; + } + onMoveY: { + parent.anchors.topMargin += dy + parent.y += dy + oy += dy + } + Label { id: label - anchors.centerIn: parent } + MouseArea { + anchors.fill: parent + onPressed: parent.forceActiveFocus() + } } diff --git a/src/constant/JamiStrings.qml b/src/constant/JamiStrings.qml index f1b7c5d89..fdf9b4419 100644 --- a/src/constant/JamiStrings.qml +++ b/src/constant/JamiStrings.qml @@ -330,9 +330,6 @@ Item { "Use the \"Link Another Device\" feature to obtain a PIN.") property string connectFromAnotherDevice: qsTr("Link device") - // KeyBoardShortcutTable - property string conversations: qsTr("Conversations") - // LinkDevicesDialog property string pinTimerInfos: qsTr("The PIN and the account password should be entered in your device within 10 minutes.") property string close: qsTr("Close") @@ -405,6 +402,8 @@ Item { // SmartList property string clearText: qsTr("Clear Text") + property string conversations: qsTr("Conversations") + property string searchResults: qsTr("Search Results") // SmartList context menu property string declineContactRequest: qsTr("Decline contact request") diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml index d7b5db5f9..43df8965d 100644 --- a/src/constant/JamiTheme.qml +++ b/src/constant/JamiTheme.qml @@ -55,10 +55,10 @@ Item { property color notificationBlue: "#31b7ff" property color unPresenceOrange: "orange" property color placeHolderTextFontColor: "#767676" - property color draftRed: "#cf5300" + property color draftTextColor: "#cf5300" property color selectedTabColor: primaryForegroundColor - property color filterBadgeColor: mediumGrey - property color filterBadgeTextColor: blackColor + property color filterBadgeColor: "#eed4d8" + property color filterBadgeTextColor: "#cc0022" // General buttons property color pressedButtonColor: darkTheme ? pressColor : "#a0a0a0" @@ -174,10 +174,14 @@ Item { property real titleFontSize: 16 property real primaryRadius: 4 property real smartlistItemFontSize: 10.5 + property real smartlistItemInfoFontSize: 9 property real filterItemFontSize: smartlistItemFontSize property real filterBadgeFontSize: 8.25 property real accountListItemHeight: 64 property real accountListAvatarSize: 40 + property real smartListItemHeight: 64 + property real smartListAvatarSize: 52 + property real smartListTransitionDuration: 120 property real maximumWidthSettingsView: 600 property real settingsHeaderpreferredHeight: 64 diff --git a/src/contactadapter.cpp b/src/contactadapter.cpp index d35cc64f2..b4b8a4392 100644 --- a/src/contactadapter.cpp +++ b/src/contactadapter.cpp @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Edric Ladent Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> @@ -48,13 +48,13 @@ ContactAdapter::getContactSelectableModel(int type) switch (listModeltype_) { case SmartListModel::Type::CONVERSATION: selectableProxyModel_->setPredicate([this](const QModelIndex& index, const QRegExp&) { - return !defaultModerators_.contains(index.data(SmartListModel::URI).toString()); + return !defaultModerators_.contains(index.data(Role::URI).toString()); }); break; case SmartListModel::Type::CONFERENCE: selectableProxyModel_->setPredicate([](const QModelIndex& index, const QRegExp&) { - return index.data(SmartListModel::Presence).toBool(); + return index.data(Role::Presence).toBool(); }); break; case SmartListModel::Type::TRANSFER: @@ -68,13 +68,11 @@ ContactAdapter::getContactSelectableModel(int type) .contactModel->bestIdForContact(conv.participants[0]); QRegExp matchExcept = QRegExp(QString("\\b(?!" + calleeDisplayId + "\\b)\\w+")); - match = matchExcept.indexIn(index.data(SmartListModel::Role::DisplayID).toString()) - != -1; + match = matchExcept.indexIn(index.data(Role::BestId).toString()) != -1; } if (match) { - match = regexp.indexIn(index.data(SmartListModel::Role::DisplayID).toString()) - != -1; + match = regexp.indexIn(index.data(Role::BestId).toString()) != -1; } return match && !index.parent().isValid(); }); @@ -95,8 +93,8 @@ ContactAdapter::setSearchFilter(const QString& filter) } else if (listModeltype_ == SmartListModel::Type::CONVERSATION) { selectableProxyModel_->setPredicate( [this, filter](const QModelIndex& index, const QRegExp&) { - return (!defaultModerators_.contains(index.data(SmartListModel::URI).toString()) - && index.data(SmartListModel::DisplayName).toString().contains(filter)); + return (!defaultModerators_.contains(index.data(Role::URI).toString()) + && index.data(Role::BestName).toString().contains(filter)); }); } selectableProxyModel_->setFilterRegExp( @@ -114,15 +112,14 @@ ContactAdapter::contactSelected(int index) switch (listModeltype_) { case SmartListModel::Type::CONFERENCE: { // Conference. - const auto sectionName = contactIndex.data(SmartListModel::Role::SectionName) - .value<QString>(); + const auto sectionName = contactIndex.data(Role::SectionName).value<QString>(); if (!sectionName.isEmpty()) { smartListModel_->toggleSection(sectionName); return; } - const auto convUid = contactIndex.data(SmartListModel::Role::UID).value<QString>(); - const auto accId = contactIndex.data(SmartListModel::Role::AccountId).value<QString>(); + const auto convUid = contactIndex.data(Role::UID).value<QString>(); + const auto accId = contactIndex.data(Role::AccountId).value<QString>(); const auto callId = lrcInstance_->getCallIdForConversationUid(convUid, accId); if (!callId.isEmpty()) { @@ -133,7 +130,7 @@ ContactAdapter::contactSelected(int index) callModel->joinCalls(thisCallId, callId); } else { - const auto contactUri = contactIndex.data(SmartListModel::Role::URI).value<QString>(); + const auto contactUri = contactIndex.data(Role::URI).value<QString>(); auto call = lrcInstance_->getCallInfoForConversation(convInfo); if (!call) { return; @@ -143,7 +140,7 @@ ContactAdapter::contactSelected(int index) } break; case SmartListModel::Type::TRANSFER: { // SIP Transfer. - const auto contactUri = contactIndex.data(SmartListModel::Role::URI).value<QString>(); + const auto contactUri = contactIndex.data(Role::URI).value<QString>(); if (convInfo.uid.isEmpty()) { return; @@ -170,7 +167,7 @@ ContactAdapter::contactSelected(int index) } } break; case SmartListModel::Type::CONVERSATION: { - const auto contactUri = contactIndex.data(SmartListModel::Role::URI).value<QString>(); + const auto contactUri = contactIndex.data(Role::URI).value<QString>(); if (contactUri.isEmpty()) { return; } diff --git a/src/contactadapter.h b/src/contactadapter.h index f5f30ea65..bb958c003 100644 --- a/src/contactadapter.h +++ b/src/contactadapter.h @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> * @@ -20,6 +20,7 @@ #include "qmladapterbase.h" #include "smartlistmodel.h" +#include "conversationlistmodel.h" #include <QObject> #include <QSortFilterProxyModel> @@ -38,30 +39,42 @@ class LRCInstance; */ class SelectableProxyModel final : public QSortFilterProxyModel { + Q_OBJECT + public: using FilterPredicate = std::function<bool(const QModelIndex&, const QRegExp&)>; - explicit SelectableProxyModel(QAbstractItemModel* parent) + explicit SelectableProxyModel(QAbstractListModel* parent = nullptr) : QSortFilterProxyModel(parent) { setSourceModel(parent); + setSortRole(ConversationList::Role::LastInteractionTimeStamp); + sort(0, Qt::DescendingOrder); + setFilterCaseSensitivity(Qt::CaseSensitivity::CaseInsensitive); } - ~SelectableProxyModel() {} void setPredicate(FilterPredicate filterPredicate) { filterPredicate_ = filterPredicate; } - virtual bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const + virtual bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override { // Accept all contacts in conversation list filtered with account type, except those in a call. - auto index = sourceModel()->index(source_row, 0, source_parent); + auto index = sourceModel()->index(sourceRow, 0, sourceParent); return filterPredicate_ ? filterPredicate_(index, filterRegExp()) : false; } + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override + { + QVariant leftData = sourceModel()->data(left, sortRole()); + QVariant rightData = sourceModel()->data(right, sortRole()); + // we're assuming the sort role data type here is some integral time + return leftData.toUInt() < rightData.toUInt(); + }; + private: - std::function<bool(const QModelIndex&, const QRegExp&)> filterPredicate_; + FilterPredicate filterPredicate_; }; class ContactAdapter final : public QmlAdapterBase @@ -73,6 +86,8 @@ public: ~ContactAdapter() = default; protected: + using Role = ConversationList::Role; + void safeInit() override {}; Q_INVOKABLE QVariant getContactSelectableModel(int type); @@ -81,10 +96,8 @@ protected: private: SmartListModel::Type listModeltype_; - - // SmartListModel is the source model of SelectableProxyModel. - std::unique_ptr<SmartListModel> smartListModel_; - std::unique_ptr<SelectableProxyModel> selectableProxyModel_; + QScopedPointer<SmartListModel> smartListModel_; + QScopedPointer<SelectableProxyModel> selectableProxyModel_; QStringList defaultModerators_; diff --git a/src/conversationlistmodel.cpp b/src/conversationlistmodel.cpp new file mode 100644 index 000000000..cd3fd035a --- /dev/null +++ b/src/conversationlistmodel.cpp @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#include "conversationlistmodel.h" + +#include "uri.h" + +ConversationListModel::ConversationListModel(LRCInstance* instance, QObject* parent) + : ConversationListModelBase(instance, parent) +{ + connect( + model_, + &ConversationModel::beginInsertRows, + this, + [this](int position, int rows) { + beginInsertRows(QModelIndex(), position, position + (rows - 1)); + }, + Qt::DirectConnection); + connect(model_, + &ConversationModel::endInsertRows, + this, + &ConversationListModel::endInsertRows, + Qt::DirectConnection); + + connect( + model_, + &ConversationModel::beginRemoveRows, + this, + [this](int position, int rows) { + beginRemoveRows(QModelIndex(), position, position + (rows - 1)); + }, + Qt::DirectConnection); + connect(model_, + &ConversationModel::endRemoveRows, + this, + &ConversationListModel::endRemoveRows, + Qt::DirectConnection); + + connect(model_, &ConversationModel::dataChanged, this, [this](int position) { + const auto index = createIndex(position, 0); + Q_EMIT ConversationListModel::dataChanged(index, index); + }); +} + +int +ConversationListModel::rowCount(const QModelIndex& parent) const +{ + // For list models only the root node (an invalid parent) should return the list's size. For all + // other (valid) parents, rowCount() should return 0 so that it does not become a tree model. + if (!parent.isValid() && model_) { + return model_->getConversations().size(); + } + return 0; +} + +QVariant +ConversationListModel::data(const QModelIndex& index, int role) const +{ + const auto& data = model_->getConversations(); + if (!index.isValid() || data.empty()) + return {}; + return dataForItem(data.at(index.row()), role); +} + +ConversationListProxyModel::ConversationListProxyModel(QAbstractListModel* model, QObject* parent) + : SelectableListProxyModel(model, parent) +{ + setSortRole(ConversationList::Role::LastInteractionTimeStamp); + sort(0, Qt::DescendingOrder); + setFilterCaseSensitivity(Qt::CaseSensitivity::CaseInsensitive); +} + +bool +ConversationListProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const +{ + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + auto rx = filterRegExp(); + auto uriStripper = URI(rx.pattern()); + bool stripScheme = (uriStripper.schemeType() < URI::SchemeType::COUNT__); + FlagPack<URI::Section> flags = URI::Section::USER_INFO | URI::Section::HOSTNAME + | URI::Section::PORT; + if (!stripScheme) { + flags |= URI::Section::SCHEME; + } + rx.setPattern(uriStripper.format(flags)); + auto uri = index.data(ConversationList::Role::URI).toString(); + auto alias = index.data(ConversationList::Role::Alias).toString(); + auto registeredName = index.data(ConversationList::Role::RegisteredName).toString(); + auto itemProfileType = index.data(ConversationList::Role::ContactType).toInt(); + auto typeFilter = static_cast<profile::Type>(itemProfileType) == currentTypeFilter_; + if (index.data(ConversationList::Role::IsBanned).toBool()) { + return typeFilter + && (rx.exactMatch(uri) || rx.exactMatch(alias) || rx.exactMatch(registeredName)); + } + return typeFilter + && (rx.indexIn(uri) != -1 || rx.indexIn(alias) != -1 || rx.indexIn(registeredName) != -1); +} + +bool +ConversationListProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + QVariant leftData = sourceModel()->data(left, sortRole()); + QVariant rightData = sourceModel()->data(right, sortRole()); + // we're assuming the sort role data type here is some integral time + return leftData.toULongLong() < rightData.toULongLong(); +} + +void +ConversationListProxyModel::setTypeFilter(const profile::Type& typeFilter) +{ + beginResetModel(); + currentTypeFilter_ = typeFilter; + endResetModel(); + updateSelection(); +}; diff --git a/src/conversationlistmodel.h b/src/conversationlistmodel.h new file mode 100644 index 000000000..3e143d740 --- /dev/null +++ b/src/conversationlistmodel.h @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "conversationlistmodelbase.h" +#include "selectablelistproxymodel.h" + +#include "api/profile.h" + +#include <QSortFilterProxyModel> + +// A wrapper view model around ConversationModel's underlying data +class ConversationListModel final : public ConversationListModelBase +{ + Q_OBJECT + +public: + explicit ConversationListModel(LRCInstance* instance, QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; +}; + +// The top level filtered and sorted model to be consumed by QML ListViews +class ConversationListProxyModel final : public SelectableListProxyModel +{ + Q_OBJECT + +public: + explicit ConversationListProxyModel(QAbstractListModel* model, QObject* parent = nullptr); + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + + Q_INVOKABLE void setTypeFilter(const profile::Type& typeFilter); + +private: + profile::Type currentTypeFilter_; +}; diff --git a/src/conversationlistmodelbase.cpp b/src/conversationlistmodelbase.cpp new file mode 100644 index 000000000..f87f5c9d5 --- /dev/null +++ b/src/conversationlistmodelbase.cpp @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2020-2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#include "conversationlistmodelbase.h" + +ConversationListModelBase::ConversationListModelBase(LRCInstance* instance, QObject* parent) + : AbstractListModelBase(parent) +{ + lrcInstance_ = instance; + model_ = lrcInstance_->getCurrentConversationModel(); +} + +int +ConversationListModelBase::columnCount(const QModelIndex& parent) const +{ + Q_UNUSED(parent) + return 1; +} + +QHash<int, QByteArray> +ConversationListModelBase::roleNames() const +{ + using namespace ConversationList; + QHash<int, QByteArray> roles; +#define X(role) roles[role] = #role; + CONV_ROLES +#undef X + return roles; +} + +QVariant +ConversationListModelBase::dataForItem(item_t item, int role) const +{ + if (item.participants.isEmpty()) { + return QVariant(); + } + // WARNING: not swarm ready + auto peerUri = item.participants[0]; + ContactModel* contactModel {nullptr}; + contact::Info contact {}; + try { + const auto& accountInfo = lrcInstance_->getAccountInfo(item.accountId); + contactModel = accountInfo.contactModel.get(); + contact = contactModel->getContact(peerUri); + } catch (...) { + return QVariant(false); + } + + // Since we are using image provider right now, image url representation should be unique to + // be able to use the image cache, account avatar will only be updated once PictureUid changed + switch (role) { + case Role::BestName: + return QVariant(contactModel->bestNameForContact(peerUri)); + case Role::BestId: + return QVariant(contactModel->bestIdForContact(peerUri)); + case Role::Presence: + return QVariant(contact.isPresent); + case Role::PictureUid: + return QVariant(contactAvatarUidMap_[peerUri]); + case Role::Alias: + return QVariant(contact.profileInfo.alias); + case Role::RegisteredName: + return QVariant(contact.registeredName); + case Role::URI: + return QVariant(peerUri); + case Role::UnreadMessagesCount: + return QVariant(item.unreadMessages); + case Role::LastInteractionTimeStamp: { + if (!item.interactions.empty()) { + auto ts = static_cast<qint32>(item.interactions.at(item.lastMessageUid).timestamp); + return QVariant(ts); + } + break; + } + case Role::LastInteractionDate: { + if (!item.interactions.empty()) { + auto& date = item.interactions.at(item.lastMessageUid).timestamp; + return QVariant(Utils::formatTimeString(date)); + } + break; + } + case Role::LastInteraction: { + if (!item.interactions.empty()) { + return QVariant(item.interactions.at(item.lastMessageUid).body); + } + break; + } + case Role::ContactType: { + return QVariant(static_cast<int>(contact.profileInfo.type)); + } + case Role::IsBanned: { + return QVariant(contact.isBanned); + } + case Role::UID: + return QVariant(item.uid); + case Role::InCall: { + const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); + if (!convInfo.uid.isEmpty()) { + auto* callModel = lrcInstance_->getCurrentCallModel(); + return QVariant(callModel->hasCall(convInfo.callId)); + } + return QVariant(false); + } + case Role::IsAudioOnly: { + const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); + if (!convInfo.uid.isEmpty()) { + auto* call = lrcInstance_->getCallInfoForConversation(convInfo); + if (call) { + return QVariant(call->isAudioOnly); + } + } + return QVariant(); + } + case Role::CallStackViewShouldShow: { + const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); + if (!convInfo.uid.isEmpty() && !convInfo.callId.isEmpty()) { + auto* callModel = lrcInstance_->getCurrentCallModel(); + const auto& call = callModel->getCall(convInfo.callId); + return QVariant(callModel->hasCall(convInfo.callId) + && ((!call.isOutgoing + && (call.status == call::Status::IN_PROGRESS + || call.status == call::Status::PAUSED + || call.status == call::Status::INCOMING_RINGING)) + || (call.isOutgoing && call.status != call::Status::ENDED))); + } + return QVariant(false); + } + case Role::CallState: { + const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); + if (!convInfo.uid.isEmpty()) { + if (auto* call = lrcInstance_->getCallInfoForConversation(convInfo)) { + return QVariant(static_cast<int>(call->status)); + } + } + return QVariant(); + } + case Role::Draft: { + if (!item.uid.isEmpty()) { + const auto draft = lrcInstance_->getContentDraft(item.uid, item.accountId); + if (!draft.isEmpty()) { + // Pencil Emoji + uint cp = 0x270F; + auto emojiString = QString::fromUcs4(&cp, 1); + return emojiString + draft; + } + } + return QVariant(""); + } + } + return QVariant(); +} + +void +ConversationListModelBase::updateContactAvatarUid(const QString& contactUri) +{ + contactAvatarUidMap_[contactUri] = Utils::generateUid(); +} + +void +ConversationListModelBase::fillContactAvatarUidMap( + const lrc::api::ContactModel::ContactInfoMap& contacts) +{ + if (contacts.size() == 0) { + contactAvatarUidMap_.clear(); + return; + } + + if (contactAvatarUidMap_.isEmpty() || contacts.size() != contactAvatarUidMap_.size()) { + bool useContacts = contacts.size() > contactAvatarUidMap_.size(); + auto contactsKeyList = contacts.keys(); + auto contactAvatarUidMapKeyList = contactAvatarUidMap_.keys(); + + for (int i = 0; + i < (useContacts ? contactsKeyList.size() : contactAvatarUidMapKeyList.size()); + ++i) { + // Insert or update + if (i < contactsKeyList.size() && !contactAvatarUidMap_.contains(contactsKeyList.at(i))) + contactAvatarUidMap_.insert(contactsKeyList.at(i), Utils::generateUid()); + // Remove + if (i < contactAvatarUidMapKeyList.size() + && !contacts.contains(contactAvatarUidMapKeyList.at(i))) + contactAvatarUidMap_.remove(contactAvatarUidMapKeyList.at(i)); + } + } +} diff --git a/src/conversationlistmodelbase.h b/src/conversationlistmodelbase.h new file mode 100644 index 000000000..fe99fdd04 --- /dev/null +++ b/src/conversationlistmodelbase.h @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2020-2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "abstractlistmodelbase.h" + +// TODO: many of these roles should probably be factored out +#define CONV_ROLES \ + X(BestName) \ + X(BestId) \ + X(Presence) \ + X(Alias) \ + X(RegisteredName) \ + X(URI) \ + X(UnreadMessagesCount) \ + X(LastInteractionTimeStamp) \ + X(LastInteractionDate) \ + X(LastInteraction) \ + X(ContactType) \ + X(IsBanned) \ + X(UID) \ + X(InCall) \ + X(IsAudioOnly) \ + X(CallStackViewShouldShow) \ + X(CallState) \ + X(SectionName) \ + X(AccountId) \ + X(PictureUid) \ + X(Draft) + +namespace ConversationList { +Q_NAMESPACE +enum Role { + DummyRole = Qt::UserRole + 1, +#define X(role) role, + CONV_ROLES +#undef X +}; +Q_ENUM_NS(Role) +} // namespace ConversationList + +// A generic wrapper view model around ConversationModel's underlying data +class ConversationListModelBase : public AbstractListModelBase +{ + Q_OBJECT + +public: + using item_t = const conversation::Info&; + + explicit ConversationListModelBase(LRCInstance* instance, QObject* parent = nullptr); + + int columnCount(const QModelIndex& parent) const override; + QHash<int, QByteArray> roleNames() const override; + + QVariant dataForItem(item_t item, int role = Qt::DisplayRole) const; + + // Update the avatar uid map to prevent the image provider from pulling from the cache + void updateContactAvatarUid(const QString& contactUri); + +protected: + using Role = ConversationList::Role; + + // Assign a uid for each contact avatar; it will serve as the PictureUid role + void fillContactAvatarUidMap(const ContactModel::ContactInfoMap& contacts); + + // Convenience pointer to be pulled from lrcinstance + ConversationModel* model_; + + // AvatarImageProvider helper + QMap<QString, QString> contactAvatarUidMap_; +}; diff --git a/src/conversationsadapter.cpp b/src/conversationsadapter.cpp index 6b7b76ba4..ade05c597 100644 --- a/src/conversationsadapter.cpp +++ b/src/conversationsadapter.cpp @@ -1,11 +1,7 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux - * Author: Edric Ladent Milaret <edric.ladent-milaret@savoirfairelinux.com> - * Author: Anthony Léonard <anthony.leonard@savoirfairelinux.com> - * Author: Olivier Soldano <olivier.soldano@savoirfairelinux.com> - * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> - * Author: Isa Nanic <isa.nanic@savoirfairelinux.com> * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> * * 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 @@ -26,35 +22,92 @@ #include "utils.h" #include "qtutils.h" #include "systemtray.h" +#include "qmlregister.h" #include <QApplication> +#include <QJsonObject> + +using namespace lrc::api; ConversationsAdapter::ConversationsAdapter(SystemTray* systemTray, LRCInstance* instance, QObject* parent) : QmlAdapterBase(instance, parent) + , currentTypeFilter_(profile::Type::RING) , systemTray_(systemTray) + , convSrcModel_(new ConversationListModel(lrcInstance_)) + , convModel_(new ConversationListProxyModel(convSrcModel_.get())) + , searchSrcModel_(new SearchResultsListModel(lrcInstance_)) + , searchModel_(new SelectableListProxyModel(searchSrcModel_.get())) { + QML_REGISTERSINGLETONTYPE_POBJECT(NS_MODELS, convModel_.get(), "ConversationListModel"); + QML_REGISTERSINGLETONTYPE_POBJECT(NS_MODELS, searchModel_.get(), "SearchResultsListModel"); + + new SelectableListProxyGroupModel({convModel_.data(), searchModel_.data()}, this); + + setTypeFilter(currentTypeFilter_); connect(this, &ConversationsAdapter::currentTypeFilterChanged, [this]() { - lrcInstance_->getCurrentConversationModel()->setFilter(currentTypeFilter_); + setTypeFilter(currentTypeFilter_); }); - connect(lrcInstance_, &LRCInstance::conversationSelected, [this]() { - auto convUid = lrcInstance_->get_selectedConvUid(); - if (!convUid.isEmpty()) { - Q_EMIT showConversation(lrcInstance_->getCurrAccId(), convUid); + connect(lrcInstance_, &LRCInstance::selectedConvUidChanged, [this]() { + auto convId = lrcInstance_->get_selectedConvUid(); + if (convId.isEmpty()) { + // deselected + convModel_->deselect(); + searchModel_->deselect(); + } else { + // selected + const auto& convInfo = lrcInstance_->getConversationFromConvUid(convId); + if (convInfo.uid.isEmpty()) + return; + + auto& accInfo = lrcInstance_->getAccountInfo(convInfo.accountId); + accInfo.conversationModel->selectConversation(convInfo.uid); + accInfo.conversationModel->clearUnreadInteractions(convInfo.uid); + + try { + // Set contact filter (for conversation tab selection) + // WARNING: not swarm ready + auto& contact = accInfo.contactModel->getContact(convInfo.participants.front()); + if (contact.profileInfo.type != profile::Type::INVALID + && contact.profileInfo.type != profile::Type::TEMPORARY) + set_currentTypeFilter(contact.profileInfo.type); + } catch (const std::out_of_range& e) { + qWarning() << e.what(); + } + + // reposition index in case of programmatic selection + // currently, this may only occur for the conversation list + // and not the search list + convModel_->selectSourceRow(lrcInstance_->indexOf(convId)); } }); + connect(lrcInstance_, &LRCInstance::draftSaved, [this](const QString& convId) { + auto row = lrcInstance_->indexOf(convId); + const auto index = convSrcModel_->index(row, 0); + Q_EMIT convSrcModel_->dataChanged(index, index); + }); + + connect(lrcInstance_, &LRCInstance::contactBanned, [this](const QString& uri) { + auto& convInfo = lrcInstance_->getConversationFromPeerUri(uri); + if (convInfo.uid.isEmpty()) + return; + auto row = lrcInstance_->indexOf(convInfo.uid); + const auto index = convSrcModel_->index(row, 0); + Q_EMIT convSrcModel_->dataChanged(index, index); + }); + + updateConversationFilterData(); + #ifdef Q_OS_LINUX // notification responses connect(systemTray_, &SystemTray::openConversationActivated, [this](const QString& accountId, const QString& convUid) { Q_EMIT lrcInstance_->notificationClicked(); - selectConversation(accountId, convUid); - Q_EMIT lrcInstance_->updateSmartList(); - Q_EMIT modelSorted(convUid); + lrcInstance_->selectConversation(convUid, accountId); }); connect(systemTray_, &SystemTray::acceptPendingActivated, @@ -80,85 +133,71 @@ ConversationsAdapter::ConversationsAdapter(SystemTray* systemTray, void ConversationsAdapter::safeInit() { + // TODO: remove these safeInits, they are possibly called + // multiple times during qml component inits conversationSmartListModel_ = new SmartListModel(this, SmartListModel::Type::CONVERSATION, lrcInstance_); Q_EMIT modelChanged(QVariant::fromValue(conversationSmartListModel_)); - connect(&lrcInstance_->behaviorController(), - &BehaviorController::showChatView, - [this](const QString& accountId, const QString& convId) { - Q_EMIT showConversation(accountId, convId); - }); - connect(&lrcInstance_->behaviorController(), &BehaviorController::newUnreadInteraction, this, - &ConversationsAdapter::onNewUnreadInteraction); + &ConversationsAdapter::onNewUnreadInteraction, + Qt::UniqueConnection); connect(&lrcInstance_->behaviorController(), &BehaviorController::newReadInteraction, this, - &ConversationsAdapter::onNewReadInteraction); + &ConversationsAdapter::onNewReadInteraction, + Qt::UniqueConnection); connect(&lrcInstance_->behaviorController(), &BehaviorController::newTrustRequest, this, - &ConversationsAdapter::onNewTrustRequest); + &ConversationsAdapter::onNewTrustRequest, + Qt::UniqueConnection); connect(&lrcInstance_->behaviorController(), &BehaviorController::trustRequestTreated, this, - &ConversationsAdapter::onTrustRequestTreated); + &ConversationsAdapter::onTrustRequestTreated, + Qt::UniqueConnection); connect(lrcInstance_, &LRCInstance::currentAccountChanged, this, - &ConversationsAdapter::onCurrentAccountIdChanged); + &ConversationsAdapter::onCurrentAccountIdChanged, + Qt::UniqueConnection); connectConversationModel(); - setProperty("currentTypeFilter", - QVariant::fromValue(lrcInstance_->getCurrentAccountInfo().profileInfo.type)); + set_currentTypeFilter(lrcInstance_->getCurrentAccountInfo().profileInfo.type); } void ConversationsAdapter::backToWelcomePage() { - deselectConversation(); + lrcInstance_->deselectConversation(); Q_EMIT navigateToWelcomePageRequested(); } void -ConversationsAdapter::selectConversation(const QString& accountId, const QString& convUid) +ConversationsAdapter::onCurrentAccountIdChanged() { - lrcInstance_->selectConversation(accountId, convUid); -} + lrcInstance_->deselectConversation(); -void -ConversationsAdapter::deselectConversation() -{ - if (lrcInstance_->get_selectedConvUid().isEmpty()) { - return; - } + convSrcModel_.reset(new ConversationListModel(lrcInstance_)); + convModel_->bindSourceModel(convSrcModel_.get()); + searchSrcModel_.reset(new SearchResultsListModel(lrcInstance_)); + searchModel_->bindSourceModel(searchSrcModel_.get()); - auto currentConversationModel = lrcInstance_->getCurrentConversationModel(); - - if (currentConversationModel == nullptr) { - return; - } - - lrcInstance_->set_selectedConvUid(); -} - -void -ConversationsAdapter::onCurrentAccountIdChanged() -{ connectConversationModel(); - setProperty("currentTypeFilter", - QVariant::fromValue(lrcInstance_->getCurrentAccountInfo().profileInfo.type)); + updateConversationFilterData(); + + set_currentTypeFilter(lrcInstance_->getCurrentAccountInfo().profileInfo.type); } void @@ -189,11 +228,9 @@ ConversationsAdapter::onNewUnreadInteraction(const QString& accountId, auto onClicked = [this, accountId, convUid, uri = interaction.authorUri] { Q_EMIT lrcInstance_->notificationClicked(); const auto& convInfo = lrcInstance_->getConversationFromConvUid(convUid, accountId); - if (!convInfo.uid.isEmpty()) { - selectConversation(accountId, convInfo.uid); - Q_EMIT lrcInstance_->updateSmartList(); - Q_EMIT modelSorted(convInfo.uid); - } + if (convInfo.uid.isEmpty()) + return; + lrcInstance_->selectConversation(convInfo.uid, accountId); }; systemTray_->showNotification(interaction.body, from, onClicked); #endif @@ -209,6 +246,10 @@ ConversationsAdapter::onNewReadInteraction(const QString& accountId, // hide notification auto notifId = QString("%1;%2;%3").arg(accountId).arg(convUid).arg(interactionId); systemTray_->hideNotification(notifId); +#else + Q_UNUSED(accountId) + Q_UNUSED(convUid) + Q_UNUSED(interactionId) #endif } @@ -227,6 +268,9 @@ ConversationsAdapter::onNewTrustRequest(const QString& accountId, const QString& NotificationType::REQUEST, Utils::QImageToByteArray(contactPhoto)); } +#else + Q_UNUSED(accountId) + Q_UNUSED(peerUri) #endif } @@ -237,6 +281,9 @@ ConversationsAdapter::onTrustRequestTreated(const QString& accountId, const QStr // hide notification auto notifId = QString("%1;%2").arg(accountId).arg(peerUri); systemTray_->hideNotification(notifId); +#else + Q_UNUSED(accountId) + Q_UNUSED(peerUri) #endif } @@ -244,46 +291,30 @@ void ConversationsAdapter::onModelChanged() { conversationSmartListModel_->fillConversationsList(); - updateConversationsFilterWidget(); - - auto* convModel = lrcInstance_->getCurrentConversationModel(); - const auto& convInfo = lrcInstance_->getConversationFromConvUid( - lrcInstance_->get_selectedConvUid()); - - if (convInfo.uid.isEmpty() || convInfo.participants.isEmpty()) { - return; - } - const auto contactURI = convInfo.participants[0]; - if (contactURI.isEmpty() - || convModel->owner.contactModel->getContact(contactURI).profileInfo.type - == lrc::api::profile::Type::TEMPORARY) { - return; - } - Q_EMIT modelSorted(QVariant::fromValue(convInfo.uid)); + updateConversationFilterData(); } void ConversationsAdapter::onProfileUpdated(const QString& contactUri) { + // TODO: this will need a dataChanged call to keep the avatar + // updated. previously, 'reload-smartlist' was invoked here conversationSmartListModel_->updateContactAvatarUid(contactUri); - Q_EMIT updateListViewRequested(); } void ConversationsAdapter::onConversationUpdated(const QString&) { - updateConversationsFilterWidget(); - Q_EMIT updateListViewRequested(); + updateConversationFilterData(); } void ConversationsAdapter::onFilterChanged() { conversationSmartListModel_->fillConversationsList(); - updateConversationsFilterWidget(); + updateConversationFilterData(); if (!lrcInstance_->get_selectedConvUid().isEmpty()) Q_EMIT indexRepositionRequested(); - Q_EMIT updateListViewRequested(); } void @@ -304,10 +335,9 @@ ConversationsAdapter::onConversationCleared(const QString& convUid) { // If currently selected, switch to welcome screen (deselecting // current smartlist item). - if (convUid != lrcInstance_->get_selectedConvUid()) { - return; + if (convUid == lrcInstance_->get_selectedConvUid()) { + lrcInstance_->deselectConversation(); } - backToWelcomePage(); } void @@ -319,26 +349,94 @@ ConversationsAdapter::onSearchStatusChanged(const QString& status) void ConversationsAdapter::onSearchResultUpdated() { + // currently for contact pickers conversationSmartListModel_->fillConversationsList(); - Q_EMIT updateListViewRequested(); + + // smartlist search results + searchSrcModel_->onSearchResultsUpdated(); } void -ConversationsAdapter::updateConversationsFilterWidget() +ConversationsAdapter::updateConversationFilterData() { - // Update status of "Conversations" and "Invitations". - auto invites = lrcInstance_->getCurrentAccountInfo().contactModel->pendingRequestCount(); - if (invites == 0 && currentTypeFilter_ == lrc::api::profile::Type::PENDING) { - setProperty("currentTypeFilter", QVariant::fromValue(lrc::api::profile::Type::RING)); + // TODO: this may be further spliced to respond separately to + // incoming messages and invites + // total unread message and pending invite counts, and tab selection + auto& accountInfo = lrcInstance_->getCurrentAccountInfo(); + int totalUnreadMessages {0}; + if (accountInfo.profileInfo.type != profile::Type::SIP) { + auto& convModel = accountInfo.conversationModel; + auto conversations = convModel->getFilteredConversations(profile::Type::RING, false); + conversations.for_each([&totalUnreadMessages](const conversation::Info& conversation) { + totalUnreadMessages += conversation.unreadMessages; + }); } - showConversationTabs(invites); + set_totalUnreadMessageCount(totalUnreadMessages); + set_pendingRequestCount(accountInfo.contactModel->pendingRequestCount()); + if (pendingRequestCount_ == 0 && currentTypeFilter_ == profile::Type::PENDING) { + set_currentTypeFilter(profile::Type::RING); + } +} + +void +ConversationsAdapter::setFilter(const QString& filterString) +{ + convModel_->setFilter(filterString); + searchSrcModel_->setFilter(filterString); } void -ConversationsAdapter::refill() +ConversationsAdapter::setTypeFilter(const profile::Type& typeFilter) { - if (conversationSmartListModel_) - conversationSmartListModel_->fillConversationsList(); + convModel_->setTypeFilter(typeFilter); +} + +QVariantMap +ConversationsAdapter::getConvInfoMap(const QString& convId) +{ + const auto& convInfo = lrcInstance_->getConversationFromConvUid(convId); + if (convInfo.participants.empty()) + return {}; + auto peerUri = convInfo.participants[0]; + ContactModel* contactModel {nullptr}; + contact::Info contact {}; + try { + const auto& accountInfo = lrcInstance_->getAccountInfo(convInfo.accountId); + contactModel = accountInfo.contactModel.get(); + contact = contactModel->getContact(peerUri); + } catch (...) { + return {}; + } + bool isAudioOnly {false}; + if (!convInfo.uid.isEmpty()) { + auto* call = lrcInstance_->getCallInfoForConversation(convInfo); + if (call) { + isAudioOnly = call->isAudioOnly; + } + } + bool callStackViewShouldShow {false}; + call::Status callState {}; + if (!convInfo.callId.isEmpty()) { + auto* callModel = lrcInstance_->getCurrentCallModel(); + const auto& call = callModel->getCall(convInfo.callId); + callStackViewShouldShow = callModel->hasCall(convInfo.callId) + && ((!call.isOutgoing + && (call.status == call::Status::IN_PROGRESS + || call.status == call::Status::PAUSED + || call.status == call::Status::INCOMING_RINGING)) + || (call.isOutgoing && call.status != call::Status::ENDED)); + callState = call.status; + } + // WARNING: not swarm ready + // titles should come from conversation, not contact model + return {{"convId", convId}, + {"bestId", contactModel->bestIdForContact(peerUri)}, + {"bestName", contactModel->bestNameForContact(peerUri)}, + {"uri", peerUri}, + {"contactType", static_cast<int>(contact.profileInfo.type)}, + {"isAudioOnly", isAudioOnly}, + {"callState", static_cast<int>(callState)}, + {"callStackViewShouldShow", callStackViewShouldShow}}; } bool @@ -402,7 +500,7 @@ ConversationsAdapter::connectConversationModel(bool updateFilter) Qt::UniqueConnection); if (updateFilter) { - currentTypeFilter_ = lrc::api::profile::Type::INVALID; + currentTypeFilter_ = profile::Type::INVALID; } return true; } @@ -421,8 +519,8 @@ ConversationsAdapter::updateConversationForNewContact(const QString& convUid) const auto contact = convModel->owner.contactModel->getContact(convInfo.participants[0]); if (!contact.profileInfo.uri.isEmpty() && contact.profileInfo.uri == lrcInstance_->get_selectedConvUid()) { - lrcInstance_->set_selectedConvUid(convUid); - convModel->selectConversation(convUid); + lrcInstance_->selectConversation(convUid, convInfo.accountId); + convModel_->selectSourceRow(lrcInstance_->indexOf(convUid)); } } catch (...) { return; diff --git a/src/conversationsadapter.h b/src/conversationsadapter.h index 32de1980e..11501ab6d 100644 --- a/src/conversationsadapter.h +++ b/src/conversationsadapter.h @@ -1,6 +1,7 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> * * 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 @@ -21,6 +22,8 @@ #include "lrcinstance.h" #include "qmladapterbase.h" #include "smartlistmodel.h" +#include "conversationlistmodel.h" +#include "searchresultslistmodel.h" #include <QObject> #include <QString> @@ -30,9 +33,10 @@ class SystemTray; class ConversationsAdapter final : public QmlAdapterBase { Q_OBJECT + QML_PROPERTY(lrc::api::profile::Type, currentTypeFilter) + QML_PROPERTY(int, totalUnreadMessageCount) + QML_PROPERTY(int, pendingRequestCount) - Q_PROPERTY(lrc::api::profile::Type currentTypeFilter MEMBER currentTypeFilter_ NOTIFY - currentTypeFilterChanged) public: explicit ConversationsAdapter(SystemTray* systemTray, LRCInstance* instance, @@ -44,25 +48,22 @@ protected: public: Q_INVOKABLE bool connectConversationModel(bool updateFilter = true); - Q_INVOKABLE void selectConversation(const QString& accountId, const QString& uid); - Q_INVOKABLE void deselectConversation(); - Q_INVOKABLE void refill(); - Q_INVOKABLE void updateConversationsFilterWidget(); + Q_INVOKABLE void setFilter(const QString& filterString); + Q_INVOKABLE void setTypeFilter(const profile::Type& typeFilter); + Q_INVOKABLE QVariantMap getConvInfoMap(const QString& convId); Q_SIGNALS: void showConversation(const QString& accountId, const QString& convUid); - void showConversationTabs(bool visible); void showSearchStatus(const QString& status); void modelChanged(const QVariant& model); - void modelSorted(const QVariant& uid); - void updateListViewRequested(); void navigateToWelcomePageRequested(); - void currentTypeFilterChanged(); void indexRepositionRequested(); private Q_SLOTS: void onCurrentAccountIdChanged(); + + // cross-account slots void onNewUnreadInteraction(const QString& accountId, const QString& convUid, uint64_t interactionId, @@ -73,6 +74,7 @@ private Q_SLOTS: void onNewTrustRequest(const QString& accountId, const QString& peerUri); void onTrustRequestTreated(const QString& accountId, const QString& peerUri); + // per-account slots void onModelChanged(); void onProfileUpdated(const QString&); void onConversationUpdated(const QString&); @@ -83,13 +85,18 @@ private Q_SLOTS: void onSearchStatusChanged(const QString&); void onSearchResultUpdated(); + void updateConversationFilterData(); + private: void backToWelcomePage(); void updateConversationForNewContact(const QString& convUid); SmartListModel* conversationSmartListModel_; - lrc::api::profile::Type currentTypeFilter_ {}; - SystemTray* systemTray_; + + QScopedPointer<ConversationListModel> convSrcModel_; + QScopedPointer<ConversationListProxyModel> convModel_; + QScopedPointer<SearchResultsListModel> searchSrcModel_; + QScopedPointer<SelectableListProxyModel> searchModel_; }; diff --git a/src/lrcinstance.cpp b/src/lrcinstance.cpp index 738701f32..facb7be48 100644 --- a/src/lrcinstance.cpp +++ b/src/lrcinstance.cpp @@ -219,6 +219,12 @@ LRCInstance::getCurrentCallModel() return getCurrentAccountInfo().callModel.get(); } +ContactModel* +LRCInstance::getCurrentContactModel() +{ + return getCurrentAccountInfo().contactModel.get(); +} + const QString& LRCInstance::getCurrAccId() { @@ -301,6 +307,18 @@ LRCInstance::getCurrAccConfig() return getCurrentAccountInfo().confProperties; } +int +LRCInstance::indexOf(const QString& convId) +{ + auto& convs = getCurrentConversationModel()->getConversations(); + auto it = std::find_if(convs.begin(), + convs.end(), + [convId](const lrc::api::conversation::Info& conv) { + return conv.uid == convId; + }); + return it != convs.end() ? std::distance(convs.begin(), it) : -1; +} + void LRCInstance::subscribeToDebugReceived() { @@ -353,6 +371,8 @@ LRCInstance::setContentDraft(const QString& convUid, { auto draftKey = accountId + "_" + convUid; contentDrafts_[draftKey] = content; + // this signal is only needed to update the current smartlist + Q_EMIT draftSaved(convUid); } void @@ -374,41 +394,24 @@ LRCInstance::poplastConference(const QString& confId) } void -LRCInstance::selectConversation(const QString& accountId, const QString& convUid) -{ - const auto& convInfo = getConversationFromConvUid(convUid, accountId); - - if (get_selectedConvUid() != convInfo.uid || convInfo.participants.size() > 0) { - // If the account is not currently selected, do that first, then - // proceed to select the conversation. - auto selectConversation = [this, accountId, convUid = convInfo.uid] { - const auto& convInfo = getConversationFromConvUid(convUid, accountId); - if (convInfo.uid.isEmpty()) { - return; - } - auto& accInfo = getAccountInfo(convInfo.accountId); - set_selectedConvUid(convInfo.uid); - accInfo.conversationModel->clearUnreadInteractions(convInfo.uid); - - try { - // Set contact filter (for conversation tab selection) - auto& contact = accInfo.contactModel->getContact(convInfo.participants.front()); - setProperty("currentTypeFilter", QVariant::fromValue(contact.profileInfo.type)); - } catch (const std::out_of_range& e) { - qDebug() << e.what(); - } - }; - if (convInfo.accountId != getCurrAccId()) { - Utils::oneShotConnect(this, &LRCInstance::currentAccountChanged, [selectConversation] { - selectConversation(); - }); - set_selectedConvUid(); - setSelectedAccountId(convInfo.accountId); - } else { - selectConversation(); - } +LRCInstance::selectConversation(const QString& convId, const QString& accountId) +{ + // if the account is not currently selected, do that first, then + // proceed to select the conversation + if (!accountId.isEmpty() && accountId != getCurrAccId()) { + Utils::oneShotConnect(this, &LRCInstance::currentAccountChanged, [this, convId] { + set_selectedConvUid(convId); + }); + setSelectedAccountId(accountId); + return; } - Q_EMIT conversationSelected(); + set_selectedConvUid(convId); +} + +void +LRCInstance::deselectConversation() +{ + set_selectedConvUid(); } void diff --git a/src/lrcinstance.h b/src/lrcinstance.h index 71119750d..e4cbe6e77 100644 --- a/src/lrcinstance.h +++ b/src/lrcinstance.h @@ -72,6 +72,7 @@ public: NewAccountModel& accountModel(); ConversationModel* getCurrentConversationModel(); NewCallModel* getCurrentCallModel(); + ContactModel* getCurrentContactModel(); AVModel& avModel(); PluginModel& pluginModel(); BehaviorController& behaviorController(); @@ -95,15 +96,18 @@ public: const conversation::Info& getConversationFromCallId(const QString& callId, const QString& accountId = {}); + Q_INVOKABLE void selectConversation(const QString& convId, const QString& accountId = {}); + Q_INVOKABLE void deselectConversation(); + const QString& getCurrAccId(); void setSelectedAccountId(const QString& accountId = {}); - void selectConversation(const QString& accountId, const QString& convUid); int getCurrentAccountIndex(); void setAvatarForAccount(const QPixmap& avatarPixmap, const QString& accountID); void setCurrAccAvatar(const QPixmap& avatarPixmap); void setCurrAccAvatar(const QString& avatar); void setCurrAccDisplayName(const QString& displayName); const account::ConfProperties_t& getCurrAccConfig(); + int indexOf(const QString& convId); void startAudioMeter(bool async); void stopAudioMeter(bool async); @@ -121,15 +125,16 @@ Q_SIGNALS: void currentAccountChanged(); void restoreAppRequested(); void notificationClicked(); - void updateSmartList(); void quitEngineRequested(); - void conversationSelected(); + void conversationUpdated(const QString& convId, const QString& accountId); + void draftSaved(const QString& convId); + void contactBanned(const QString& uri); private: std::unique_ptr<Lrc> lrc_; std::unique_ptr<RenderManager> renderer_; std::unique_ptr<UpdateManager> updateManager_; - QString selectedAccountId_ {""}; + QString selectedAccountId_ {}; MapStringString contentDrafts_; MapStringString lastConferences_; diff --git a/src/mainapplication.h b/src/mainapplication.h index 881155d5c..58c8a67f6 100644 --- a/src/mainapplication.h +++ b/src/mainapplication.h @@ -34,6 +34,7 @@ class ConnectivityMonitor; class AppSettingsManager; class SystemTray; +class CallAdapter; // Provides information about the screen the app is displayed on class ScreenInfo : public QObject @@ -98,4 +99,6 @@ private: SystemTray* systemTray_; ScreenInfo screenInfo_; + + CallAdapter* callAdapter_; }; diff --git a/src/mainview/MainView.qml b/src/mainview/MainView.qml index b1669e49c..1c565dbb5 100644 --- a/src/mainview/MainView.qml +++ b/src/mainview/MainView.qml @@ -59,8 +59,6 @@ Rectangle { property string currentAccountId: AccountAdapter.currentAccountId onCurrentAccountIdChanged: { - var index = UtilsAdapter.getCurrAccList().indexOf(currentAccountId) - mainViewSidePanel.refreshAccountComboBox(index) if (inSettingsView) { settingsView.accountListChanged() settingsView.setSelected(settingsView.selectedMenu, true) @@ -80,7 +78,7 @@ Rectangle { function showWelcomeView() { currentConvUID = "" callStackView.needToCloseInCallConversationAndPotentialWindow() - mainViewSidePanel.deselectConversationSmartList() + LRCInstance.deselectConversation() if (isPageInStack("callStackViewObject", sidePanelViewStack) || isPageInStack("communicationPageMessageWebView", sidePanelViewStack) || isPageInStack("communicationPageMessageWebView", mainViewStack) || @@ -140,8 +138,7 @@ Rectangle { if (checkCurrentCall && currentAccountIsCalling()) { var callConv = UtilsAdapter.getCallConvForAccount( AccountAdapter.currentAccountId) - ConversationsAdapter.selectConversation( - AccountAdapter.currentAccountId, callConv) + LRCInstance.selectConversation(callConv) CallAdapter.updateCall(callConv, currentAccountId) } else { showWelcomeView() @@ -171,56 +168,50 @@ Rectangle { } } - // ConversationSmartListViewItemDelegate provides UI information - function setMainView(currentUserDisplayName, currentUserAlias, currentUID, - callStackViewShouldShow, isAudioOnly, callState) { + function setMainView(convId) { if (!(communicationPageMessageWebView.jsLoaded)) { communicationPageMessageWebView.jsLoadedChanged.connect( - function(currentUserDisplayName, currentUserAlias, currentUID, - callStackViewShouldShow, isAudioOnly, callState) { - return function() { - setMainView(currentUserDisplayName, currentUserAlias, currentUID, - callStackViewShouldShow, isAudioOnly, callState) - } - }(currentUserDisplayName, currentUserAlias, currentUID, - callStackViewShouldShow, isAudioOnly, callState)) + function(convId) { + return function() { setMainView(convId) } + }(convId)) return } - - if (callStackViewShouldShow) { + var item = ConversationsAdapter.getConvInfoMap(convId) + if (item.convId === undefined) + return + communicationPageMessageWebView.headerUserAliasLabelText = item.bestName + communicationPageMessageWebView.headerUserUserNameLabelText = item.bestId + if (item.callStackViewShouldShow) { if (inSettingsView) { toggleSettingsView() } - MessagesAdapter.setupChatView(currentUID) - communicationPageMessageWebView.headerUserAliasLabelText = currentUserAlias - communicationPageMessageWebView.headerUserUserNameLabelText = currentUserDisplayName + MessagesAdapter.setupChatView(convId) callStackView.setLinkedWebview(communicationPageMessageWebView) callStackView.responsibleAccountId = AccountAdapter.currentAccountId - callStackView.responsibleConvUid = currentUID - currentConvUID = currentUID + callStackView.responsibleConvUid = convId + currentConvUID = convId - if (callState === Call.Status.IN_PROGRESS || callState === Call.Status.PAUSED) { - CallAdapter.updateCall(currentUID, AccountAdapter.currentAccountId) - if (isAudioOnly) + if (item.callState === Call.Status.IN_PROGRESS || + item.callState === Call.Status.PAUSED) { + CallAdapter.updateCall(convId, AccountAdapter.currentAccountId) + if (item.isAudioOnly) callStackView.showAudioCallPage() else callStackView.showVideoCallPage() - } else if (callState === Call.Status.INCOMING_RINGING) { + } else if (item.callState === Call.Status.INCOMING_RINGING) { callStackView.showIncomingCallPage() } else { - callStackView.showOutgoingCallPage(callState) + callStackView.showOutgoingCallPage(item.callState) } pushCallStackView() } else if (!inSettingsView) { - if (currentConvUID !== currentUID) { + if (currentConvUID !== convId) { callStackView.needToCloseInCallConversationAndPotentialWindow() - MessagesAdapter.setupChatView(currentUID) - communicationPageMessageWebView.headerUserAliasLabelText = currentUserAlias - communicationPageMessageWebView.headerUserUserNameLabelText = currentUserDisplayName + MessagesAdapter.setupChatView(convId) pushCommunicationMessageWebView() communicationPageMessageWebView.focusMessageWebView() - currentConvUID = currentUID + currentConvUID = convId } else if (isPageInStack("callStackViewObject", sidePanelViewStack) || isPageInStack("callStackViewObject", mainViewStack)) { callStackView.needToCloseInCallConversationAndPotentialWindow() @@ -233,11 +224,16 @@ Rectangle { color: JamiTheme.backgroundColor Connections { - target: CallAdapter + target: LRCInstance + + function onSelectedConvUidChanged() { + mainView.setMainView(LRCInstance.selectedConvUid) + } - // selectConversation causes UI update - function onCallSetupMainViewRequired(accountId, convUid) { - ConversationsAdapter.selectConversation(accountId, convUid) + function onConversationUpdated(convUid, accountId) { + if (convUid === LRCInstance.selectedConvUid && + accountId === currentAccountId) + mainView.setMainView(convUid) } } @@ -291,12 +287,6 @@ Rectangle { Connections { target: AccountAdapter - function onUpdateConversationForAddedContact() { - MessagesAdapter.updateConversationForAddedContact() - mainViewSidePanel.clearContactSearchBar() - mainViewSidePanel.forceReselectConversationSmartListCurrentIndex() - } - function onAccountStatusChanged(accountId) { accountComboBox.resetAccountListModel(accountId) } @@ -438,17 +428,12 @@ Rectangle { Connections { target: MessagesAdapter - function onNeedToUpdateSmartList() { - mainViewSidePanel.forceUpdateConversationSmartListView() - } - function onNavigateToWelcomePageRequested() { backToMainView() } function onInvitationAccepted() { mainViewSidePanel.selectTab(SidePanelTabBar.Conversations) - showWelcomeView() } } diff --git a/src/mainview/components/AccountComboBox.qml b/src/mainview/components/AccountComboBox.qml index 80df7b93f..cbcd7f1df 100644 --- a/src/mainview/components/AccountComboBox.qml +++ b/src/mainview/components/AccountComboBox.qml @@ -34,7 +34,20 @@ Label { signal settingBtnClicked property alias popup: comboBoxPopup - // Reset accountListModel. + // TODO: remove these refresh hacks use QAbstractItemModels correctly + Connections { + target: AccountAdapter + + function onCurrentAccountIdChanged() { + root.update() + resetAccountListModel(AccountAdapter.currentAccountId) + } + + function onAccountStatusChanged(accountId) { + resetAccountListModel(accountId) + } + } + function resetAccountListModel(accountId) { accountListModel.updateAvatarUid(accountId) accountListModel.reset() diff --git a/src/mainview/components/AccountComboBoxPopup.qml b/src/mainview/components/AccountComboBoxPopup.qml index 4db441bcd..3b0128d44 100644 --- a/src/mainview/components/AccountComboBoxPopup.qml +++ b/src/mainview/components/AccountComboBoxPopup.qml @@ -112,11 +112,11 @@ Popup { layer { enabled: true effect: DropShadow { - color: JamiTheme.shadowColor - verticalOffset: 2 - horizontalOffset: 2 + horizontalOffset: 3.0 + verticalOffset: 3.0 + radius: 16.0 samples: 16 - radius: 10 + color: JamiTheme.shadowColor } } } diff --git a/src/mainview/components/BadgeNotifier.qml b/src/mainview/components/BadgeNotifier.qml new file mode 100644 index 000000000..90aa14327 --- /dev/null +++ b/src/mainview/components/BadgeNotifier.qml @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.14 + +import net.jami.Constants 1.0 + +Rectangle { + id: root + + property real size + property int count: 0 + property int lastCount: count + property bool populated: false + property bool animate: true + + width: size + height: size + + radius: JamiTheme.primaryRadius + color: JamiTheme.filterBadgeColor + + visible: count > 0 + + Text { + id: countLabel + + anchors.centerIn: root + text: count > 9 ? "…" : count + color: JamiTheme.filterBadgeTextColor + font.pointSize: JamiTheme.filterBadgeFontSize + font.weight: Font.ExtraBold + } + + onCountChanged: { + if (count > lastCount && animate) + notifyAnim.start() + lastCount = count + if (!populated) + populated = true + } + ParallelAnimation { + id: notifyAnim + + ColorAnimation { + target: root; properties: "color" + from: JamiTheme.filterBadgeTextColor + to: JamiTheme.filterBadgeColor + duration: 150; easing.type: Easing.InOutQuad + } + ColorAnimation { + target: countLabel; properties: "color" + from: JamiTheme.filterBadgeColor + to: JamiTheme.filterBadgeTextColor + duration: 150; easing.type: Easing.InOutQuad + } + NumberAnimation { + target: root; property: "y" + from: -3; to: 0 + duration: 150; easing.type: Easing.InOutQuad + } + } +} diff --git a/src/mainview/components/CallOverlay.qml b/src/mainview/components/CallOverlay.qml index b0c611e95..1e03f290d 100644 --- a/src/mainview/components/CallOverlay.qml +++ b/src/mainview/components/CallOverlay.qml @@ -399,7 +399,7 @@ Rectangle { onAddToConferenceButtonClicked: { // Create contact picker - conference. ContactPickerCreation.createContactPickerObjects( - ContactPicker.ContactPickerType.JAMICONFERENCE, + ContactList.CONFERENCE, callOverlayRect) ContactPickerCreation.openContactPicker() } @@ -517,7 +517,7 @@ Rectangle { onTransferCallButtonClicked: { // Create contact picker - sip transfer. ContactPickerCreation.createContactPickerObjects( - ContactPicker.ContactPickerType.SIPTRANSFER, + ContactList.TRANSFER, callOverlayRect) ContactPickerCreation.openContactPicker() } diff --git a/src/mainview/components/ContactPicker.qml b/src/mainview/components/ContactPicker.qml index c2165b59b..24110c82f 100644 --- a/src/mainview/components/ContactPicker.qml +++ b/src/mainview/components/ContactPicker.qml @@ -30,15 +30,7 @@ import "../../commoncomponents" Popup { id: contactPickerPopup - property int type: ContactPicker.ContactPickerType.JAMICONFERENCE - - - // Important to keep it one, since enum in c++ starts at one for conferences. - enum ContactPickerType { - CONVERSATION = 0, - JAMICONFERENCE, - SIPTRANSFER - } + property int type: ContactList.CONFERENCE contentWidth: 250 contentHeight: contactPickerPopupRectColumnLayout.height + 50 @@ -89,9 +81,9 @@ Popup { text: { switch(type) { - case ContactPicker.ContactPickerType.JAMICONFERENCE: + case ContactList.CONFERENCE: return qsTr("Add to conference") - case ContactPicker.ContactPickerType.SIPTRANSFER: + case ContactList.TRANSFER: return qsTr("Transfer this call") default: return qsTr("Add default moderator") diff --git a/src/mainview/components/ContactPickerItemDelegate.qml b/src/mainview/components/ContactPickerItemDelegate.qml index bd3aa2379..d71bd2bcf 100644 --- a/src/mainview/components/ContactPickerItemDelegate.qml +++ b/src/mainview/components/ContactPickerItemDelegate.qml @@ -66,7 +66,7 @@ ItemDelegate { font: contactPickerContactName.font elide: Text.ElideMiddle elideWidth: contactPickerContactInfoRect.width - text: DisplayName + text: BestName } color: JamiTheme.textColor @@ -88,7 +88,7 @@ ItemDelegate { font: contactPickerContactId.font elide: Text.ElideMiddle elideWidth: contactPickerContactInfoRect.width - text: DisplayID == DisplayName ? "" : DisplayID + text: BestId == BestName ? "" : BestId } text: textMetricsContactPickerContactId.elidedText diff --git a/src/mainview/components/ContactSearchBar.qml b/src/mainview/components/ContactSearchBar.qml index 6cdc25a6a..1f9dbc754 100644 --- a/src/mainview/components/ContactSearchBar.qml +++ b/src/mainview/components/ContactSearchBar.qml @@ -32,6 +32,8 @@ Rectangle { signal contactSearchBarTextChanged(string text) signal returnPressedWhileSearching + property alias textContent: contactSearchBar.text + function clearText() { contactSearchBar.clear() fakeFocus.forceActiveFocus() @@ -108,10 +110,11 @@ Rectangle { anchors.right: root.right anchors.rightMargin: 10 - preferredSize: 20 + preferredSize: 21 radius: JamiTheme.primaryRadius visible: contactSearchBar.text.length + opacity: visible ? 1 : 0 normalColor: root.color imageColor: JamiTheme.primaryForegroundColor @@ -120,6 +123,10 @@ Rectangle { toolTipText: JamiStrings.clearText onClicked: contactSearchBar.clear() + + Behavior on opacity { + NumberAnimation { duration: 500; easing.type: Easing.OutCubic } + } } Shortcut { diff --git a/src/mainview/components/ConversationListView.qml b/src/mainview/components/ConversationListView.qml new file mode 100644 index 000000000..48981cd50 --- /dev/null +++ b/src/mainview/components/ConversationListView.qml @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 + +import net.jami.Models 1.0 +import net.jami.Adapters 1.0 +import net.jami.Constants 1.0 + +ListView { + id: root + + // the following should be marked required (Qtver >= 5.15) + // along with `required model` + property string headerLabel + property bool headerVisible + + delegate: SmartListItemDelegate {} + currentIndex: model.currentFilteredRow + + clip: true + maximumFlickVelocity: 1024 + ScrollIndicator.vertical: ScrollIndicator {} + + // highlight selection + // down and hover states are done within the delegate + highlight: Rectangle { + width: ListView.view ? ListView.view.width : 0 + color: JamiTheme.selectedColor + } + highlightMoveDuration: 60 + + headerPositioning: ListView.OverlayHeader + header: Rectangle { + z: 2 + color: JamiTheme.backgroundColor + visible: root.headerVisible + width: root.width + height: root.headerVisible ? 20 : 0 + Text { + anchors { + left: parent.left + leftMargin: 16 + verticalCenter: parent.verticalCenter + } + text: headerLabel + " (" + root.count + ")" + font.pointSize: JamiTheme.smartlistItemFontSize + font.weight: Font.DemiBold + color: JamiTheme.textColor + } + } + + Connections { + target: model + + // actually select the conversation + function onValidSelectionChanged() { + var row = model.currentFilteredRow + var convId = model.dataForRow(row, ConversationList.UID) + LRCInstance.selectConversation(convId) + } + } + + onCountChanged: positionViewAtBeginning() + + Component.onCompleted: { + // TODO: remove this + ConversationsAdapter.setQmlObject(this) + } + + add: Transition { + NumberAnimation { + property: "opacity"; from: 0; to: 1.0 + duration: JamiTheme.smartListTransitionDuration + } + } + + displaced: Transition { + NumberAnimation { + properties: "x,y"; easing.type: Easing.OutCubic + duration: JamiTheme.smartListTransitionDuration + } + NumberAnimation { + property: "opacity"; to: 1.0 + duration: JamiTheme.smartListTransitionDuration * (1 - from) + } + } + + Behavior on opacity { + NumberAnimation { + easing.type: Easing.OutCubic + duration: 2 * JamiTheme.smartListTransitionDuration + } + } + + function openContextMenuAt(x, y, delegate) { + var mappedCoord = root.mapFromItem(delegate, x, y) + contextMenu.openMenuAt(mappedCoord.x, mappedCoord.y) + } + + ConversationSmartListContextMenu { + id: contextMenu + + function openMenuAt(x, y) { + contextMenu.x = x + contextMenu.y = y + + // TODO: + // - accountId, convId only + // - userProfile dialog should use a loader/popup + + var row = root.indexAt(x, y + root.contentY) + var item = { + "convId": model.dataForRow(row, ConversationList.UID), + "displayId": model.dataForRow(row, ConversationList.BestId), + "displayName": model.dataForRow(row, ConversationList.BestName), + "uri": model.dataForRow(row, ConversationList.URI), + "contactType": model.dataForRow(row, ConversationList.ContactType), + } + + responsibleAccountId = AccountAdapter.currentAccountId + responsibleConvUid = item.convId + contactType = item.contactType + + userProfile.responsibleConvUid = item.convId + userProfile.aliasText = item.displayName + userProfile.registeredNameText = item.displayId + userProfile.idText = item.uri + userProfile.contactImageUid = item.convId + + openMenu() + } + } + + Shortcut { + sequence: "Ctrl+Shift+X" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: { + CallAdapter.placeCall() + communicationPageMessageWebView.setSendContactRequestButtonVisible(false) + } + } + + Shortcut { + sequence: "Ctrl+Shift+C" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: { + CallAdapter.placeAudioOnlyCall() + communicationPageMessageWebView.setSendContactRequestButtonVisible(false) + } + } + + Shortcut { + sequence: "Ctrl+Shift+L" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: MessagesAdapter.clearConversationHistory( + AccountAdapter.currentAccountId, + UtilsAdapter.getCurrConvId()) + } + + Shortcut { + sequence: "Ctrl+Shift+B" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: { + MessagesAdapter.blockConversation(UtilsAdapter.getCurrConvId()) + } + } + + Shortcut { + sequence: "Ctrl+Shift+Delete" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: MessagesAdapter.removeConversation( + AccountAdapter.currentAccountId, + UtilsAdapter.getCurrConvId(), + false) + } + + Shortcut { + sequence: "Ctrl+Down" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: { + if (currentIndex + 1 >= count) + return + model.select(currentIndex + 1) + } + } + + Shortcut { + sequence: "Ctrl+Up" + context: Qt.ApplicationShortcut + enabled: root.visible + onActivated: { + if (currentIndex <= 0) + return + model.select(currentIndex - 1) + } + } +} diff --git a/src/mainview/components/ConversationSmartListContextMenu.qml b/src/mainview/components/ConversationSmartListContextMenu.qml index ab4200d09..c4a3bce54 100644 --- a/src/mainview/components/ConversationSmartListContextMenu.qml +++ b/src/mainview/components/ConversationSmartListContextMenu.qml @@ -34,6 +34,8 @@ Item { property string responsibleConvUid: "" property int contactType: Profile.Type.INVALID + function isOpen() { return ContextMenuGenerator.getMenu().visible } + function openMenu() { ContextMenuGenerator.initMenu() var hasCall = UtilsAdapter.getCallId(responsibleAccountId, responsibleConvUid) !== "" @@ -41,18 +43,14 @@ Item { ContextMenuGenerator.addMenuItem(qsTr("Start video call"), "qrc:/images/icons/videocam-24px.svg", function (){ - ConversationsAdapter.selectConversation( - responsibleAccountId, - responsibleConvUid, false) + LRCInstance.selectConversation(responsibleConvUid, responsibleAccountId) CallAdapter.placeCall() communicationPageMessageWebView.setSendContactRequestButtonVisible(false) }) ContextMenuGenerator.addMenuItem(qsTr("Start audio call"), "qrc:/images/icons/place_audiocall-24px.svg", function (){ - ConversationsAdapter.selectConversation( - responsibleAccountId, - responsibleConvUid, false) + LRCInstance.selectConversation(responsibleConvUid, responsibleAccountId) CallAdapter.placeAudioOnlyCall() communicationPageMessageWebView.setSendContactRequestButtonVisible(false) }) @@ -116,7 +114,7 @@ Item { }) } ContextMenuGenerator.addMenuSeparator() - ContextMenuGenerator.addMenuItem(qsTr("Profile"), + ContextMenuGenerator.addMenuItem(qsTr("Contact details"), "qrc:/images/icons/person-24px.svg", function (){ userProfile.open() diff --git a/src/mainview/components/ConversationSmartListView.qml b/src/mainview/components/ConversationSmartListView.qml deleted file mode 100644 index a4f4dda5a..000000000 --- a/src/mainview/components/ConversationSmartListView.qml +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (C) 2020 by Savoir-faire Linux - * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> - * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> - * - * 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 3 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, see <https://www.gnu.org/licenses/>. - */ - -import QtQuick 2.14 -import QtQuick.Controls 2.14 -import QtQuick.Layouts 1.14 - -import net.jami.Models 1.0 -import net.jami.Adapters 1.0 - -ListView { - id: root - - signal needToDeselectItems - signal forceUpdatePotentialInvalidItem - - // Refresh all items within the model. - function updateListView() { - if (!root.model) - return - root.model.dataChanged( - root.model.index(0, 0), - root.model.index( - root.model.rowCount() - 1, 0)) - root.forceUpdatePotentialInvalidItem() - } - - function repositionIndex(uid = "") { - // Only update index if it has changed - var currentI = root.currentIndex - if (uid === "") - uid = mainView.currentConvUID - root.currentIndex = -1 - updateListView() - for (var i = 0; i < count; i++) { - if (root.model.data( - root.model.index(i, 0), SmartListModel.UID) === uid) { - root.currentIndex = i - break - } - } - } - - ConversationSmartListContextMenu { - id: smartListContextMenu - } - - Connections { - target: ConversationsAdapter - - function onModelChanged(model) { - root.model = model - } - - // When the model has been sorted, we need to adjust the focus (currentIndex) - // to the previously focused conversation item. - function onModelSorted(uid) { - repositionIndex(uid) - } - - function onUpdateListViewRequested() { - updateListView() - } - - function onIndexRepositionRequested() { - repositionIndex() - } - } - - Connections { - target: LRCInstance - function onUpdateSmartList() { updateListView() } - } - - clip: true - maximumFlickVelocity: 1024 - - delegate: ConversationSmartListViewItemDelegate { - id: smartListItemDelegate - - onUpdateContactAvatarUidRequested: root.model.updateContactAvatarUid(uid) - } - - ScrollIndicator.vertical: ScrollIndicator {} - - Shortcut { - sequence: "Ctrl+Shift+X" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: { - CallAdapter.placeCall() - communicationPageMessageWebView.setSendContactRequestButtonVisible(false) - } - } - - Shortcut { - sequence: "Ctrl+Shift+C" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: { - CallAdapter.placeAudioOnlyCall() - communicationPageMessageWebView.setSendContactRequestButtonVisible(false) - } - } - - Shortcut { - sequence: "Ctrl+Shift+L" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: MessagesAdapter.clearConversationHistory( - AccountAdapter.currentAccountId, - UtilsAdapter.getCurrConvId()) - } - - Shortcut { - sequence: "Ctrl+Shift+B" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: { - MessagesAdapter.blockConversation(UtilsAdapter.getCurrConvId()) - } - } - - Shortcut { - sequence: "Ctrl+Shift+Delete" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: MessagesAdapter.removeConversation( - AccountAdapter.currentAccountId, - UtilsAdapter.getCurrConvId(), - false) - } - - Shortcut { - sequence: "Ctrl+Down" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: { - if (currentIndex + 1 >= count) - return - root.currentIndex += 1 - } - } - - Shortcut { - sequence: "Ctrl+Up" - context: Qt.ApplicationShortcut - enabled: root.visible - onActivated: { - if (currentIndex <= 0) - return - root.currentIndex -= 1 - } - } -} diff --git a/src/mainview/components/ConversationSmartListViewItemDelegate.qml b/src/mainview/components/ConversationSmartListViewItemDelegate.qml deleted file mode 100644 index cc4ca6d4f..000000000 --- a/src/mainview/components/ConversationSmartListViewItemDelegate.qml +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright (C) 2020 by Savoir-faire Linux - * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> - * - * 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 3 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, see <https://www.gnu.org/licenses/>. - */ - -import QtQuick 2.14 -import QtQuick.Controls 2.14 -import QtQuick.Layouts 1.14 - -import net.jami.Models 1.0 -import net.jami.Adapters 1.0 -import net.jami.Constants 1.0 - -import "../../commoncomponents" - -ItemDelegate { - id: smartListItemDelegate - height: 72 - - property int lastInteractionPreferredWidth: 80 - - signal updateContactAvatarUidRequested(string uid) - - property bool openedMenu: false - - function convUid() { - return UID - } - - Connections { - target: conversationSmartListView - - // Hack, make sure that smartListItemDelegate does not show extra item - // when searching new contacts. - function onForceUpdatePotentialInvalidItem() { - smartListItemDelegate.visible = - conversationSmartListView.model.rowCount() <= index ? false : true - } - - - // When currentIndex is -1, deselect items, if not, change select item - function onCurrentIndexChanged() { - if (conversationSmartListView.currentIndex === -1 - || conversationSmartListView.currentIndex !== index) { - itemSmartListBackground.color = Qt.binding(function () { - return InCall ? Qt.lighter(JamiTheme.selectionBlue, - 1.8) : JamiTheme.backgroundColor - }) - } else { - itemSmartListBackground.color = Qt.binding(function () { - return InCall ? Qt.lighter(JamiTheme.selectionBlue, - 1.8) : JamiTheme.selectedColor - }) - ConversationsAdapter.selectConversation( - AccountAdapter.currentAccountId, UID) - } - } - } - - Connections { - target: ConversationsAdapter - - function onShowConversation(accountId, convUid) { - if (convUid === UID) { - mainView.setMainView(DisplayID == DisplayName ? "" : DisplayID, - DisplayName, UID, CallStackViewShouldShow, IsAudioOnly, CallState) - } - } - } - - AvatarImage { - id: conversationSmartListUserImage - - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 16 - - width: 40 - height: 40 - - mode: AvatarImage.Mode.FromContactUri - - showPresenceIndicator: Presence === undefined ? false : Presence - - unreadMessagesCount: UnreadMessagesCount - - Component.onCompleted: { - var contactUid = URI - if (ContactType === Profile.Type.TEMPORARY) - updateContactAvatarUidRequested(contactUid) - updateImage(contactUid, PictureUid) - } - } - - RowLayout { - id: rowUsernameAndLastInteractionDate - anchors.left: conversationSmartListUserImage.right - anchors.leftMargin: 16 - anchors.top: parent.top - anchors.topMargin: conversationSmartListUserLastInteractionMessage.text !== "" ? - 16 : parent.height/2-conversationSmartListUserName.height/2 - anchors.right: parent.right - anchors.rightMargin: 10 - - Text { - id: conversationSmartListUserName - Layout.alignment: conversationSmartListUserLastInteractionMessage.text !== "" ? - Qt.AlignLeft : Qt.AlignLeft | Qt.AlignVCenter - - TextMetrics { - id: textMetricsConversationSmartListUserName - font: conversationSmartListUserName.font - elide: Text.ElideRight - elideWidth: LastInteractionDate ? (smartListItemDelegate.width - lastInteractionPreferredWidth - - conversationSmartListUserImage.width-32) - : smartListItemDelegate.width - lastInteractionPreferredWidth - text: DisplayName === undefined ? "" : DisplayName - } - text: textMetricsConversationSmartListUserName.elidedText - font.pointSize: JamiTheme.smartlistItemFontSize - color: JamiTheme.textColor - } - - Text { - id: conversationSmartListUserLastInteractionDate - Layout.alignment: Qt.AlignRight - TextMetrics { - id: textMetricsConversationSmartListUserLastInteractionDate - font: conversationSmartListUserLastInteractionDate.font - elide: Text.ElideRight - elideWidth: lastInteractionPreferredWidth - text: LastInteractionDate === undefined ? "" : LastInteractionDate - } - - text: textMetricsConversationSmartListUserLastInteractionDate.elidedText - font.pointSize: JamiTheme.textFontSize - color: JamiTheme.faddedLastInteractionFontColor - } - } - - Text { - id: conversationSmartListUserLastInteractionMessage - - anchors.left: conversationSmartListUserImage.right - anchors.leftMargin: 16 - anchors.bottom: rowUsernameAndLastInteractionDate.bottom - anchors.bottomMargin: -20 - - TextMetrics { - id: textMetricsConversationSmartListUserLastInteractionMessage - font: conversationSmartListUserLastInteractionMessage.font - elide: Text.ElideRight - elideWidth: LastInteractionDate ? (smartListItemDelegate.width - lastInteractionPreferredWidth - - conversationSmartListUserImage.width-32) - : smartListItemDelegate.width - lastInteractionPreferredWidth - text: InCall ? UtilsAdapter.getCallStatusStr(CallState) : (Draft ? Draft : LastInteraction) - } - - font.family: Qt.platform.os === "windows" ? "Segoe UI Emoji" : Qt.application.font.family - font.hintingPreference: Font.PreferNoHinting - text: textMetricsConversationSmartListUserLastInteractionMessage.elidedText - maximumLineCount: 1 - font.pointSize: JamiTheme.textFontSize - color: Draft ? JamiTheme.draftRed : JamiTheme.faddedLastInteractionFontColor - } - - background: Rectangle { - id: itemSmartListBackground - color: InCall ? Qt.lighter(JamiTheme.selectionBlue, 1.8) : JamiTheme.backgroundColor - implicitWidth: conversationSmartListView.width - implicitHeight: parent.height - border.width: 0 - } - - MouseArea { - id: mouseAreaSmartListItemDelegate - - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - - function openContextMenu(mouse) { - openedMenu = true - smartListContextMenu.parent = mouseAreaSmartListItemDelegate - - // Make menu pos at mouse. - var relativeMousePos = mapToItem(itemSmartListBackground, - mouse.x, mouse.y) - smartListContextMenu.x = relativeMousePos.x - smartListContextMenu.y = relativeMousePos.y - smartListContextMenu.responsibleAccountId = AccountAdapter.currentAccountId - smartListContextMenu.responsibleConvUid = UID - smartListContextMenu.contactType = ContactType - userProfile.responsibleConvUid = UID - userProfile.aliasText = DisplayName - userProfile.registeredNameText = DisplayID - userProfile.idText = URI - userProfile.contactImageUid = UID - smartListContextMenu.openMenu() - } - - onPressed: { - if (!InCall) { - itemSmartListBackground.color = JamiTheme.pressColor - } - } - onDoubleClicked: { - if (!InCall) { - ConversationsAdapter.selectConversation(AccountAdapter.currentAccountId, - UID, - false) - if (AccountAdapter.currentAccountType === Profile.Type.SIP) - CallAdapter.placeAudioOnlyCall() - else - CallAdapter.placeCall() - communicationPageMessageWebView.setSendContactRequestButtonVisible(false) - } - } - onPressAndHold: { - openContextMenu(mouse) - } - onReleased: { - if (!InCall) { - itemSmartListBackground.color = JamiTheme.selectionBlue - } - if (mouse.button === Qt.RightButton) { - openContextMenu(mouse) - } else if (mouse.button === Qt.LeftButton && !openedMenu) { - conversationSmartListView.currentIndex = -1 - conversationSmartListView.currentIndex = index - } - openedMenu = false - } - onEntered: { - if (!InCall) { - itemSmartListBackground.color = JamiTheme.hoverColor - } - } - onExited: { - if (!InCall) { - if (conversationSmartListView.currentIndex !== index - || conversationSmartListView.currentIndex === -1) { - itemSmartListBackground.color = Qt.binding(function () { - return InCall ? Qt.lighter(JamiTheme.selectionBlue, - 1.8) : JamiTheme.backgroundColor - }) - } else { - itemSmartListBackground.color = Qt.binding(function () { - return InCall ? Qt.lighter(JamiTheme.selectionBlue, - 1.8) : JamiTheme.selectedColor - }) - } - } - } - } -} diff --git a/src/mainview/components/FilterTabButton.qml b/src/mainview/components/FilterTabButton.qml index 5f70ac661..19021fd32 100644 --- a/src/mainview/components/FilterTabButton.qml +++ b/src/mainview/components/FilterTabButton.qml @@ -20,10 +20,7 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 import QtQuick.Layouts 1.14 -import QtGraphicalEffects 1.14 -import net.jami.Models 1.0 -import net.jami.Adapters 1.0 import net.jami.Constants 1.0 import "../../commoncomponents" @@ -34,7 +31,7 @@ TabButton { property var tabBar: undefined property alias labelText: label.text property alias acceleratorSequence: accelerator.sequence - property int badgeCount + property alias badgeCount: badge.count signal selected hoverEnabled: true @@ -57,32 +54,17 @@ TabButton { id: label Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + Layout.bottomMargin: 1 font.pointSize: JamiTheme.filterItemFontSize - color: Qt.lighter(JamiTheme.textColor, - root.down == true ? 1.0 : 1.5) + color: JamiTheme.textColor + opacity: root.down ? 1.0 : 0.5 } - Rectangle { - id: badgeRect - - readonly property real size: 20 - + BadgeNotifier { + id: badge + size: 20 Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter - - width: size - height: size - radius: JamiTheme.primaryRadius - color: JamiTheme.filterBadgeColor - - visible: badgeCount > 0 - - Text { - anchors.centerIn: badgeRect - text: badgeCount > 9 ? "…" : badgeCount - color: JamiTheme.filterBadgeTextColor - font.pointSize: JamiTheme.filterBadgeFontSize - } } } } @@ -91,9 +73,7 @@ TabButton { width: rect.width anchors.bottom: rect.bottom height: 2 - color: root.down === true ? - JamiTheme.textColor : - "transparent" + color: root.down ? JamiTheme.textColor : "transparent" } Shortcut { diff --git a/src/mainview/components/MessageWebView.qml b/src/mainview/components/MessageWebView.qml index f2bad1d50..442d6abd6 100644 --- a/src/mainview/components/MessageWebView.qml +++ b/src/mainview/components/MessageWebView.qml @@ -108,6 +108,14 @@ Rectangle { } } + Connections { + target: AccountAdapter + + function onSelectedContactAdded(convId) { + MessagesAdapter.updateConversationForAddedContact() + } + } + JamiFileDialog { id: jamiFileDialog diff --git a/src/mainview/components/SidePanel.qml b/src/mainview/components/SidePanel.qml index fde7cc288..d86ba5cb3 100644 --- a/src/mainview/components/SidePanel.qml +++ b/src/mainview/components/SidePanel.qml @@ -1,6 +1,7 @@ /* - * Copyright (C) 2020 by Savoir-faire Linux + * Copyright (C) 2020-2021 by Savoir-faire Linux * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> * * 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 @@ -32,60 +33,29 @@ Rectangle { color: JamiTheme.backgroundColor - property bool tabBarVisible: true - property int pendingRequestCount: 0 - property int totalUnreadMessagesCount: 0 - - // Hack -> force redraw. - function forceReselectConversationSmartListCurrentIndex() { - var index = conversationSmartListView.currentIndex - conversationSmartListView.currentIndex = -1 - conversationSmartListView.currentIndex = index - } - + anchors.fill: parent - // For contact request conv to be focused correctly. - function setCurrentUidSmartListModelIndex() { - conversationSmartListView.currentIndex - = conversationSmartListView.model.currentUidSmartListModelIndex() - } + Connections { + target: AccountAdapter - function updatePendingRequestCount() { - pendingRequestCount = UtilsAdapter.getTotalPendingRequest() - } + function onCurrentAccountIdChanged() { + clearContactSearchBar() + } - function updateTotalUnreadMessagesCount() { - totalUnreadMessagesCount = UtilsAdapter.getTotalUnreadMessages() + function onSelectedContactAdded(convId) { + clearContactSearchBar() + LRCInstance.selectConversation(convId) + } } function clearContactSearchBar() { contactSearchBar.clearText() } - function refreshAccountComboBox(index) { - accountComboBox.update() - clearContactSearchBar() - accountComboBox.resetAccountListModel() - } - - function deselectConversationSmartList() { - ConversationsAdapter.deselectConversation() - conversationSmartListView.currentIndex = -1 - } - - function forceUpdateConversationSmartListView() { - conversationSmartListView.updateListView() - } - function selectTab(tabIndex) { sidePanelTabBar.selectTab(tabIndex) } - // Intended -> since strange behavior will happen without this for stackview. - anchors.top: parent.top - anchors.fill: parent - - // Search bar container to embed search label ContactSearchBar { id: contactSearchBar @@ -98,116 +68,114 @@ Rectangle { anchors.rightMargin: 15 onContactSearchBarTextChanged: { - UtilsAdapter.setConversationFilter(text) + // not calling positionViewAtBeginning will cause + // sort animation visual bugs + conversationListView.positionViewAtBeginning() + ConversationsAdapter.setFilter(text) } onReturnPressedWhileSearching: { - var convUid = conversationSmartListView.itemAtIndex(0).convUid() - var currentAccountId = AccountAdapter.currentAccountId - ConversationsAdapter.selectConversation(currentAccountId, convUid) - conversationSmartListView.repositionIndex(convUid) + var listView = searchResultsListView.count ? + searchResultsListView : + conversationListView + if (listView.count) + listView.model.select(0) } } SidePanelTabBar { id: sidePanelTabBar + + visible: ConversationsAdapter.pendingRequestCount && + !contactSearchBar.textContent anchors.top: contactSearchBar.bottom - anchors.topMargin: 10 + anchors.topMargin: visible ? 10 : 0 width: sidePanelRect.width - height: tabBarVisible ? 42 : 0 + height: visible ? 42 : 0 } Rectangle { id: searchStatusRect - visible: lblSearchStatus.text !== "" + visible: searchStatusText.text !== "" - anchors.top: tabBarVisible ? sidePanelTabBar.bottom : contactSearchBar.bottom - anchors.topMargin: tabBarVisible ? 0 : 10 + anchors.top: sidePanelTabBar.bottom + anchors.topMargin: visible ? 10 : 0 width: parent.width - height: 72 - - color: "transparent" - - Image { - id: searchIcon - anchors.left: searchStatusRect.left - anchors.leftMargin: 24 - anchors.verticalCenter: searchStatusRect.verticalCenter - width: 24 - height: 24 - - layer { - enabled: true - effect: ColorOverlay { - color: JamiTheme.textColor - } - } + height: visible ? 42 : 0 - fillMode: Image.PreserveAspectFit - mipmap: true - source: "qrc:/images/icons/ic_baseline-search-24px.svg" - } + color: JamiTheme.backgroundColor - Label { - id: lblSearchStatus + Text { + id: searchStatusText - anchors.verticalCenter: searchStatusRect.verticalCenter - anchors.left: searchIcon.right - anchors.leftMargin: 24 - width: searchStatusRect.width - searchIcon.width - 24*2 - 8 - text: "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 32 + anchors.right: parent.right + anchors.rightMargin: 32 color: JamiTheme.textColor wrapMode: Text.WordWrap - font.pointSize: JamiTheme.menuFontSize + font.pointSize: JamiTheme.filterItemFontSize } + } - MouseArea { - id: mouseAreaSearchRect - - anchors.fill: parent - hoverEnabled: true - - onReleased: { - searchStatusRect.color = Qt.binding(function(){return JamiTheme.normalButtonColor}) - } - - onEntered: { - searchStatusRect.color = Qt.binding(function(){return JamiTheme.hoverColor}) - } + Connections { + target: ConversationsAdapter - onExited: { - searchStatusRect.color = Qt.binding(function(){return JamiTheme.backgroundColor}) - } + function onShowSearchStatus(status) { + searchStatusText.text = status } } - ConversationSmartListView { - id: conversationSmartListView + ColumnLayout { + id: smartListLayout - anchors.top: searchStatusRect.visible ? searchStatusRect.bottom : (tabBarVisible ? sidePanelTabBar.bottom : contactSearchBar.bottom) - anchors.topMargin: (tabBarVisible || searchStatusRect.visible) ? 0 : 10 width: parent.width - height: tabBarVisible ? sidePanelRect.height - sidePanelTabBar.height - contactSearchBar.height - 20 : - sidePanelRect.height - contactSearchBar.height - 20 - - Connections { - target: ConversationsAdapter - - function onShowConversationTabs(visible) { - tabBarVisible = visible - updatePendingRequestCount() - updateTotalUnreadMessagesCount() + anchors.top: searchStatusRect.bottom + anchors.topMargin: (sidePanelTabBar.visible || + searchStatusRect.visible) ? 0 : 12 + anchors.bottom: parent.bottom + + spacing: 4 + + ConversationListView { + id: searchResultsListView + + visible: count + opacity: visible ? 1 :0 + + Layout.topMargin: 10 + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + Layout.preferredHeight: visible ? contentHeight : 0 + Layout.maximumHeight: { + var otherContentHeight = conversationListView.contentHeight + 16 + if (conversationListView.visible) + if (otherContentHeight < parent.height / 2) + return parent.height - otherContentHeight + else + return parent.height / 2 + else + return parent.height } - function onShowSearchStatus(status) { - lblSearchStatus.text = status - } + model: SearchResultsListModel + headerLabel: JamiStrings.searchResults + headerVisible: visible } - Component.onCompleted: { - ConversationsAdapter.setQmlObject(this) - conversationSmartListView.currentIndex = -1 + ConversationListView { + id: conversationListView + + visible: count + + Layout.preferredWidth: parent.width + Layout.fillHeight: true + + model: ConversationListModel + headerLabel: JamiStrings.conversations + headerVisible: searchResultsListView.visible } } } diff --git a/src/mainview/components/SidePanelTabBar.qml b/src/mainview/components/SidePanelTabBar.qml index cd5f51ab9..04cd82dde 100644 --- a/src/mainview/components/SidePanelTabBar.qml +++ b/src/mainview/components/SidePanelTabBar.qml @@ -28,31 +28,18 @@ import net.jami.Constants 1.0 import "../../commoncomponents" -// TODO: -// - totalUnreadMessagesCount and pendingRequestCount could be -// properties of ConversationsAdapter -// - onCurrentTypeFilterChanged shouldn't need to update the smartlist -// - tabBarVisible could be factored out - TabBar { id: tabBar + property int currentTypeFilter: ConversationsAdapter.currentTypeFilter + + currentIndex: 0 + enum TabIndex { Conversations, Requests } - Connections { - target: ConversationsAdapter - - function onCurrentTypeFilterChanged() { - pageOne.down = ConversationsAdapter.currentTypeFilter !== Profile.Type.PENDING - pageTwo.down = ConversationsAdapter.currentTypeFilter === Profile.Type.PENDING - setCurrentUidSmartListModelIndex() - forceReselectConversationSmartListCurrentIndex() - } - } - function selectTab(tabIndex) { ConversationsAdapter.currentTypeFilter = (tabIndex === SidePanelTabBar.Conversations) ? @@ -60,28 +47,25 @@ TabBar { Profile.Type.PENDING } - visible: tabBarVisible - - currentIndex: 0 - FilterTabButton { - id: pageOne + id: conversationsTabButton + down: currentTypeFilter !== Profile.Type.PENDING tabBar: parent - down: true labelText: JamiStrings.conversations onSelected: selectTab(SidePanelTabBar.Conversations) - badgeCount: totalUnreadMessagesCount + badgeCount: ConversationsAdapter.totalUnreadMessageCount acceleratorSequence: "Ctrl+L" } FilterTabButton { - id: pageTwo + id: requestsTabButton + down: !conversationsTabButton.down tabBar: parent labelText: JamiStrings.invitations onSelected: selectTab(SidePanelTabBar.Requests) - badgeCount: pendingRequestCount + badgeCount: ConversationsAdapter.pendingRequestCount acceleratorSequence: "Ctrl+R" } } diff --git a/src/mainview/components/SmartListItemDelegate.qml b/src/mainview/components/SmartListItemDelegate.qml new file mode 100644 index 000000000..fcabcf499 --- /dev/null +++ b/src/mainview/components/SmartListItemDelegate.qml @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2020-2021 by Savoir-faire Linux + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 + +import net.jami.Models 1.0 +import net.jami.Adapters 1.0 +import net.jami.Constants 1.0 + +import "../../commoncomponents" + +ItemDelegate { + id: root + + width: ListView.view.width + height: JamiTheme.smartListItemHeight + + function convUid() { + return UID + } + + Component.onCompleted: { + if (ContactType === Profile.Type.TEMPORARY) + root.ListView.view.model.updateContactAvatarUid(URI) + avatar.updateImage(URI, PictureUid) + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 10 + + AvatarImage { + id: avatar + + Connections { + target: root.ListView.view.model + function onDataChanged(index) { + var model = root.ListView.view.model + avatar.updateImage(URI === undefined ? + model.data(index, ConversationList.URI): + URI, + PictureUid === undefined ? + model.data(index, ConversationList.PictureUid): + PictureUid) + } + } + + Layout.preferredWidth: JamiTheme.smartListAvatarSize + Layout.preferredHeight: JamiTheme.smartListAvatarSize + + mode: AvatarImage.Mode.FromContactUri + showPresenceIndicator: Presence === undefined ? false : Presence + transitionDuration: 0 + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + // best name + Text { + Layout.fillWidth: true + Layout.preferredHeight: 20 + Layout.alignment: Qt.AlignVCenter + elide: Text.ElideRight + text: BestName === undefined ? "" : BestName + font.pointSize: JamiTheme.smartlistItemFontSize + font.weight: UnreadMessagesCount ? Font.Bold : Font.Normal + color: JamiTheme.textColor + } + RowLayout { + visible: ContactType !== Profile.Type.TEMPORARY + && LastInteractionDate !== undefined + Layout.fillWidth: true + Layout.preferredHeight: 20 + Layout.alignment: Qt.AlignTop + // last Interaction date + Text { + Layout.alignment: Qt.AlignVCenter + text: LastInteractionDate === undefined ? "" : LastInteractionDate + font.pointSize: JamiTheme.smartlistItemInfoFontSize + font.weight: UnreadMessagesCount ? Font.DemiBold : Font.Normal + color: JamiTheme.textColor + } + // last Interaction + Text { + elide: Text.ElideRight + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: Draft ? + Draft : + (LastInteraction === undefined ? "" : LastInteraction) + font.pointSize: JamiTheme.smartlistItemInfoFontSize + font.weight: UnreadMessagesCount ? Font.Normal : Font.Light + font.hintingPreference: Font.PreferNoHinting + maximumLineCount: 1 + color: JamiTheme.textColor + // deal with poor rendering of the pencil emoji on Windows + font.family: Qt.platform.os === "windows" && Draft ? + "Segoe UI Emoji" : + Qt.application.font.family + lineHeight: font.family === "Segoe UI Emoji" ? 1.25 : 1 + } + } + } + + ColumnLayout { + visible: InCall || UnreadMessagesCount + Layout.preferredWidth: childrenRect.width + Layout.fillHeight: true + spacing: 2 + // call status + Text { + Layout.preferredHeight: 20 + Layout.alignment: Qt.AlignRight + text: InCall ? UtilsAdapter.getCallStatusStr(CallState) : "" + font.pointSize: JamiTheme.smartlistItemInfoFontSize + font.weight: Font.Medium + color: JamiTheme.textColor + } + // unread message count + Item { + Layout.preferredWidth: childrenRect.width + Layout.preferredHeight: childrenRect.height + Layout.alignment: Qt.AlignTop | Qt.AlignRight + BadgeNotifier { + size: 20 + count: UnreadMessagesCount + animate: index === 0 + } + } + } + } + + background: Rectangle { + color: { + if (root.pressed) + return Qt.darker(JamiTheme.selectedColor, 1.1) + else if (root.hovered) + return Qt.darker(JamiTheme.selectedColor, 1.05) + else + return "transparent" + } + } + + onClicked: ListView.view.model.select(index) + onDoubleClicked: { + ListView.view.model.select(index) + if (AccountAdapter.currentAccountType === Profile.Type.SIP) + CallAdapter.placeAudioOnlyCall() + else + CallAdapter.placeCall() + + // TODO: factor this out (visible should be observing) + communicationPageMessageWebView.setSendContactRequestButtonVisible(false) + } + onPressAndHold: ListView.view.openContextMenuAt(pressX, pressY, root) + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: root.ListView.view.openContextMenuAt(mouse.x, mouse.y, root) + } +} diff --git a/src/messagesadapter.cpp b/src/messagesadapter.cpp index 829b89a64..ecc4a90b2 100644 --- a/src/messagesadapter.cpp +++ b/src/messagesadapter.cpp @@ -153,8 +153,6 @@ MessagesAdapter::connectConversationModel() Q_UNUSED(convUid); removeInteraction(interactionId); }); - - currentConversationModel->setFilter(""); } void @@ -169,7 +167,7 @@ MessagesAdapter::sendContactRequest() void MessagesAdapter::updateConversationForAddedContact() { - auto* convModel = lrcInstance_->getCurrentConversationModel(); + auto convModel = lrcInstance_->getCurrentConversationModel(); const auto& convInfo = lrcInstance_->getConversationFromConvUid( lrcInstance_->get_selectedConvUid()); @@ -193,7 +191,6 @@ MessagesAdapter::slotSendMessageContentSaved(const QString& content) auto restoredContent = lrcInstance_->getContentDraft(lrcInstance_->get_selectedConvUid(), lrcInstance_->getCurrAccId()); setSendMessageContent(restoredContent); - Q_EMIT needToUpdateSmartList(); } void @@ -202,7 +199,6 @@ MessagesAdapter::slotUpdateDraft(const QString& content) if (!LastConvUid_.isEmpty()) { lrcInstance_->setContentDraft(LastConvUid_, lrcInstance_->getCurrAccId(), content); } - Q_EMIT needToUpdateSmartList(); } void @@ -460,11 +456,9 @@ MessagesAdapter::setConversationProfileData(const lrc::api::conversation::Info& try { auto& contact = accInfo->contactModel->getContact(contactUri); auto bestName = accInfo->contactModel->bestNameForContact(contactUri); - setInvitation(contact.profileInfo.type == lrc::api::profile::Type::PENDING - || contact.profileInfo.type == lrc::api::profile::Type::TEMPORARY, + setInvitation(contact.profileInfo.type == lrc::api::profile::Type::PENDING, bestName, contactUri); - if (!contact.profileInfo.avatar.isEmpty()) { setSenderImage(contactUri, contact.profileInfo.avatar); } else { diff --git a/src/messagesadapter.h b/src/messagesadapter.h index 8fdcbf859..350ad1dd7 100644 --- a/src/messagesadapter.h +++ b/src/messagesadapter.h @@ -92,7 +92,6 @@ protected: void contactIsComposing(const QString& convUid, const QString& contactUri, bool isComposing); Q_SIGNALS: - void needToUpdateSmartList(); void contactBanned(); void navigateToWelcomePageRequested(); void invitationAccepted(); diff --git a/src/qmlregister.cpp b/src/qmlregister.cpp index b5bb33904..862ae16ba 100644 --- a/src/qmlregister.cpp +++ b/src/qmlregister.cpp @@ -27,6 +27,7 @@ #include "moderatorlistmodel.h" #include "deviceitemlistmodel.h" #include "smartlistmodel.h" +#include "conversationlistmodelbase.h" #include "appsettingsmanager.h" #include "distantrenderer.h" @@ -106,6 +107,10 @@ registerTypes() QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel); QML_REGISTERTYPE(NS_MODELS, SmartListModel); + // Roles & type enums for models + QML_REGISTERNAMESPACE(NS_MODELS, ConversationList::staticMetaObject, "ConversationList"); + QML_REGISTERNAMESPACE(NS_MODELS, ContactList::staticMetaObject, "ContactList"); + // QQuickItems QML_REGISTERTYPE(NS_MODELS, PreviewRenderer); QML_REGISTERTYPE(NS_MODELS, VideoCallPreviewRenderer); diff --git a/src/qmlregister.h b/src/qmlregister.h index 896a2dc2f..b2a86842a 100644 --- a/src/qmlregister.h +++ b/src/qmlregister.h @@ -42,13 +42,12 @@ Q_CLASSINFO("RegisterEnumClassesUnscoped", "false") QQmlEngine::setObjectOwnership(I, QQmlEngine::CppOwnership); \ { using T = std::remove_reference<decltype(*I)>::type; \ qmlRegisterSingletonType<T>(NS, VER_MAJ, VER_MIN, N, \ - [I](QQmlEngine*, QJSEngine*) -> QObject* { \ - return I; }); } + [i=I](QQmlEngine*, QJSEngine*) -> QObject* { \ + return i; }); } #define QML_REGISTERSINGLETONTYPE_CUSTOM(NS, T, P) \ qmlRegisterSingletonType<T>(NS, VER_MAJ, VER_MIN, #T, \ - [p=P](QQmlEngine* e, QJSEngine* se) -> QObject* { \ - Q_UNUSED(e); Q_UNUSED(se); \ + [p=P](QQmlEngine*, QJSEngine*) -> QObject* { \ return p; \ }); // clang-format on diff --git a/src/searchresultslistmodel.cpp b/src/searchresultslistmodel.cpp new file mode 100644 index 000000000..8329524a9 --- /dev/null +++ b/src/searchresultslistmodel.cpp @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#include "searchresultslistmodel.h" + +SearchResultsListModel::SearchResultsListModel(LRCInstance* instance, QObject* parent) + : ConversationListModelBase(instance, parent) +{} + +int +SearchResultsListModel::rowCount(const QModelIndex& parent) const +{ + // For list models only the root node (an invalid parent) should return the list's size. For all + // other (valid) parents, rowCount() should return 0 so that it does not become a tree model. + if (!parent.isValid() && model_) { + return model_->getAllSearchResults().size(); + } + return 0; +} + +QVariant +SearchResultsListModel::data(const QModelIndex& index, int role) const +{ + const auto& data = model_->getAllSearchResults(); + if (!index.isValid() || data.empty()) + return {}; + return dataForItem(data.at(index.row()), role); +} + +void +SearchResultsListModel::setFilter(const QString& filterString) +{ + model_->setFilter(filterString); +} + +void +SearchResultsListModel::onSearchResultsUpdated() +{ + beginResetModel(); + fillContactAvatarUidMap(lrcInstance_->getCurrentAccountInfo().contactModel->getAllContacts()); + endResetModel(); +} diff --git a/src/searchresultslistmodel.h b/src/searchresultslistmodel.h new file mode 100644 index 000000000..372302b30 --- /dev/null +++ b/src/searchresultslistmodel.h @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "conversationlistmodelbase.h" +#include "selectablelistproxymodel.h" + +// A wrapper view model around ConversationModel's search result data +class SearchResultsListModel : public ConversationListModelBase +{ + Q_OBJECT + +public: + explicit SearchResultsListModel(LRCInstance* instance, QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + Q_INVOKABLE void setFilter(const QString& filterString); + +public Q_SLOTS: + void onSearchResultsUpdated(); +}; + +// The top level pre sorted and filtered model to be consumed by QML ListViews +class SearchResultsListProxyModel final : public SelectableListProxyModel +{ + Q_OBJECT + +public: + explicit SearchResultsListProxyModel(QAbstractListModel* model, QObject* parent = nullptr) + : SelectableListProxyModel(model, parent) {}; +}; diff --git a/src/selectablelistproxymodel.cpp b/src/selectablelistproxymodel.cpp new file mode 100644 index 000000000..b0bfab1db --- /dev/null +++ b/src/selectablelistproxymodel.cpp @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#include "selectablelistproxymodel.h" + +SelectableListProxyModel::SelectableListProxyModel(QAbstractListModel* model, QObject* parent) + : QSortFilterProxyModel(parent) + , currentFilteredRow_(-1) + , selectedSourceIndex_(QModelIndex()) +{ + bindSourceModel(model); +} + +void +SelectableListProxyModel::bindSourceModel(QAbstractListModel* model) +{ + setSourceModel(model); + connect(sourceModel(), + &QAbstractListModel::dataChanged, + this, + &SelectableListProxyModel::updateSelection, + Qt::UniqueConnection); + connect(model, + &QAbstractListModel::rowsInserted, + this, + &SelectableListProxyModel::updateSelection, + Qt::UniqueConnection); + connect(model, + &QAbstractListModel::rowsRemoved, + this, + &SelectableListProxyModel::updateSelection, + Qt::UniqueConnection); + connect(sourceModel(), + &QAbstractListModel::modelReset, + this, + &SelectableListProxyModel::deselect, + Qt::UniqueConnection); +} + +void +SelectableListProxyModel::setFilter(const QString& filterString) +{ + setFilterFixedString(filterString); + updateSelection(); +} + +void +SelectableListProxyModel::select(const QModelIndex& index) +{ + selectedSourceIndex_ = mapToSource(index); + updateSelection(); +} + +void +SelectableListProxyModel::select(int row) +{ + select(index(row, 0)); +} + +void +SelectableListProxyModel::deselect() +{ + selectedSourceIndex_ = QModelIndex(); + currentFilteredRow_ = -1; + Q_EMIT currentFilteredRowChanged(); +} + +QVariant +SelectableListProxyModel::dataForRow(int row, int role) const +{ + return data(index(row, 0), role); +} + +void +SelectableListProxyModel::selectSourceRow(int row) +{ + // note: the convId <-> index binding loop present + // is broken here + if (row == -1 || selectedSourceIndex_.row() == row) + return; + selectedSourceIndex_ = sourceModel()->index(row, 0); + updateSelection(); +} + +void +SelectableListProxyModel::updateContactAvatarUid(const QString& contactUri) +{ + auto base = qobject_cast<ConversationListModelBase*>(sourceModel()); + if (base) + base->updateContactAvatarUid(contactUri); +} + +void +SelectableListProxyModel::updateSelection() +{ + // if there has been no valid selection made, there is + // nothing to update + if (!selectedSourceIndex_.isValid() && currentFilteredRow_ == -1) + return; + + auto lastFilteredRow = currentFilteredRow_; + auto filteredIndex = mapFromSource(selectedSourceIndex_); + + // if the source model is empty, invalidate the selection + if (sourceModel()->rowCount() == 0) { + set_currentFilteredRow(-1); + Q_EMIT validSelectionChanged(); + return; + } + + // if the source and filtered index is no longer valid + // this would indicate that a mutation has occured, + // thus any arbritrary ux decision is okay here + if (!selectedSourceIndex_.isValid()) { + auto row = qMax(--currentFilteredRow_, 0); + selectedSourceIndex_ = mapToSource(index(row, 0)); + filteredIndex = mapFromSource(selectedSourceIndex_); + currentFilteredRow_ = filteredIndex.row(); + Q_EMIT currentFilteredRowChanged(); + Q_EMIT validSelectionChanged(); + return; + } + + // update the row for ListView observers + set_currentFilteredRow(filteredIndex.row()); + + // finally, if the filter index is invalid, then we have + // probably just filtered out the selected item and don't + // want to force reselection of other ui components, as the + // source index is still valid, in that case, or if the + // row hasn't changed, don't notify + if (filteredIndex.isValid() && lastFilteredRow != currentFilteredRow_) { + Q_EMIT validSelectionChanged(); + } +} + +SelectableListProxyGroupModel::SelectableListProxyGroupModel(QList<SelectableListProxyModel*> models, + QObject* parent) + : QObject(parent) + , models_(models) +{ + Q_FOREACH (auto* m, models_) { + connect(m, &SelectableListProxyModel::validSelectionChanged, [this, m] { + // deselct all other lists in the group + Q_FOREACH (auto* otherM, models_) { + if (m != otherM) { + otherM->deselect(); + } + } + }); + } +} diff --git a/src/selectablelistproxymodel.h b/src/selectablelistproxymodel.h new file mode 100644 index 000000000..af5b529fa --- /dev/null +++ b/src/selectablelistproxymodel.h @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * 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 3 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, see <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "conversationlistmodelbase.h" + +#include <QSortFilterProxyModel> + +// The base class for a filtered and sorted model. +// The model may be part of a group and if so, will track a +// mutually exclusive selection. +class SelectableListProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + QML_PROPERTY(int, currentFilteredRow) + +public: + explicit SelectableListProxyModel(QAbstractListModel* model, QObject* parent = nullptr); + + void bindSourceModel(QAbstractListModel* model); + + Q_INVOKABLE void setFilter(const QString& filterString); + Q_INVOKABLE void select(const QModelIndex& index); + Q_INVOKABLE void select(int row); + Q_INVOKABLE void deselect(); + Q_INVOKABLE QVariant dataForRow(int row, int role) const; + void selectSourceRow(int row); + + // this may not be the best place for this but it prevents a level of + // inheritance and prevents code duplication + Q_INVOKABLE void updateContactAvatarUid(const QString& contactUri); + +public Q_SLOTS: + void updateSelection(); + +Q_SIGNALS: + void validSelectionChanged(); + +private: + QPersistentModelIndex selectedSourceIndex_; +}; + +class SelectableListProxyGroupModel : public QObject +{ + Q_OBJECT +public: + explicit SelectableListProxyGroupModel(QList<SelectableListProxyModel*> models, + QObject* parent = nullptr); + QList<SelectableListProxyModel*> models_; +}; diff --git a/src/settingsview/components/AdvancedCallSettings.qml b/src/settingsview/components/AdvancedCallSettings.qml index fb567b145..239421ff7 100644 --- a/src/settingsview/components/AdvancedCallSettings.qml +++ b/src/settingsview/components/AdvancedCallSettings.qml @@ -232,7 +232,7 @@ ColumnLayout { onClicked: { ContactPickerCreation.createContactPickerObjects( - ContactPicker.ContactPickerType.CONVERSATION, + ContactList.CONVERSATION, mainView) ContactPickerCreation.openContactPicker() } diff --git a/src/settingsview/components/CurrentAccountSettings.qml b/src/settingsview/components/CurrentAccountSettings.qml index 457814ad3..144efab6f 100644 --- a/src/settingsview/components/CurrentAccountSettings.qml +++ b/src/settingsview/components/CurrentAccountSettings.qml @@ -113,7 +113,7 @@ Rectangle { id: deleteAccountDialog onAccepted: { - AccountAdapter.setSelectedConvId() + LRCInstance.deselectConversation() if(UtilsAdapter.getAccountListSize() > 0) { navigateToMainView() diff --git a/src/smartlistmodel.cpp b/src/smartlistmodel.cpp index 6269f43dd..805218ff5 100644 --- a/src/smartlistmodel.cpp +++ b/src/smartlistmodel.cpp @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2017-2020 by Savoir-faire Linux * Author: Anthony Léonard <anthony.leonard@savoirfairelinux.com> * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> @@ -34,17 +34,14 @@ SmartListModel::SmartListModel(QObject* parent, SmartListModel::Type listModelType, LRCInstance* instance) - : AbstractListModelBase(parent) + : ConversationListModelBase(instance, parent) , listModelType_(listModelType) { - lrcInstance_ = instance; if (listModelType_ == Type::CONFERENCE) { setConferenceableFilter(); } } -SmartListModel::~SmartListModel() {} - int SmartListModel::rowCount(const QModelIndex& parent) const { @@ -70,102 +67,75 @@ SmartListModel::rowCount(const QModelIndex& parent) const return 0; } -int -SmartListModel::columnCount(const QModelIndex& parent) const -{ - Q_UNUSED(parent); - return 1; -} - QVariant SmartListModel::data(const QModelIndex& index, int role) const { - if (!index.isValid()) { - return QVariant(); - } - - try { - auto& currentAccountInfo = lrcInstance_->accountModel().getAccountInfo( - lrcInstance_->getCurrAccId()); - auto& convModel = currentAccountInfo.conversationModel; - if (listModelType_ == Type::TRANSFER) { + if (!index.isValid()) + return {}; + + switch (listModelType_) { + case Type::TRANSFER: { + try { + auto& currentAccountInfo = lrcInstance_->accountModel().getAccountInfo( + lrcInstance_->getCurrAccId()); + auto& convModel = currentAccountInfo.conversationModel; auto filterType = currentAccountInfo.profileInfo.type; auto& item = convModel->getFilteredConversations(filterType).at(index.row()); - return getConversationItemData(item, currentAccountInfo, role); - } else if (listModelType_ == Type::CONFERENCE) { - auto calls = conferenceables_[ConferenceableItem::CALL]; - auto contacts = conferenceables_[ConferenceableItem::CONTACT]; - QString itemConvUid {}, itemAccountId {}; - if (calls.size() == 0) { - itemConvUid = contacts.at(index.row()).at(0).convId; - itemAccountId = contacts.at(index.row()).at(0).accountId; - } else { - bool callsOpen = sectionState_[tr("Calls")]; - bool contactsOpen = sectionState_[tr("Contacts")]; - auto callSectionEnd = callsOpen ? calls.size() + 1 : 1; - auto contactSectionEnd = contactsOpen ? callSectionEnd + contacts.size() + 1 - : callSectionEnd + 1; - if (index.row() < callSectionEnd) { - if (index.row() == 0) { - return QVariant(role == Role::SectionName - ? (callsOpen ? "➖ " : "➕ ") + QString(tr("Calls")) - : ""); - } else { - auto idx = index.row() - 1; - itemConvUid = calls.at(idx).at(0).convId; - itemAccountId = calls.at(idx).at(0).accountId; - } - } else if (index.row() < contactSectionEnd) { - if (index.row() == callSectionEnd) { - return QVariant(role == Role::SectionName - ? (contactsOpen ? "➖ " : "➕ ") + QString(tr("Contacts")) - : ""); - } else { - auto idx = index.row() - (callSectionEnd + 1); - itemConvUid = contacts.at(idx).at(0).convId; - itemAccountId = contacts.at(idx).at(0).accountId; - } + return dataForItem(item, role); + } catch (const std::exception& e) { + qWarning() << e.what(); + } + } break; + case Type::CONFERENCE: { + auto calls = conferenceables_[ConferenceableItem::CALL]; + auto contacts = conferenceables_[ConferenceableItem::CONTACT]; + QString itemConvUid {}, itemAccountId {}; + if (calls.size() == 0) { + itemConvUid = contacts.at(index.row()).at(0).convId; + itemAccountId = contacts.at(index.row()).at(0).accountId; + } else { + bool callsOpen = sectionState_[tr("Calls")]; + bool contactsOpen = sectionState_[tr("Contacts")]; + auto callSectionEnd = callsOpen ? calls.size() + 1 : 1; + auto contactSectionEnd = contactsOpen ? callSectionEnd + contacts.size() + 1 + : callSectionEnd + 1; + if (index.row() < callSectionEnd) { + if (index.row() == 0) { + return QVariant(role == Role::SectionName + ? (callsOpen ? "➖ " : "➕ ") + QString(tr("Calls")) + : ""); + } else { + auto idx = index.row() - 1; + itemConvUid = calls.at(idx).at(0).convId; + itemAccountId = calls.at(idx).at(0).accountId; + } + } else if (index.row() < contactSectionEnd) { + if (index.row() == callSectionEnd) { + return QVariant(role == Role::SectionName + ? (contactsOpen ? "➖ " : "➕ ") + QString(tr("Contacts")) + : ""); + } else { + auto idx = index.row() - (callSectionEnd + 1); + itemConvUid = contacts.at(idx).at(0).convId; + itemAccountId = contacts.at(idx).at(0).accountId; } } - if (role == Role::AccountId) { - return QVariant(itemAccountId); - } - - auto& itemAccountInfo = lrcInstance_->accountModel().getAccountInfo(itemAccountId); - auto& item = lrcInstance_->getConversationFromConvUid(itemConvUid, itemAccountId); - return getConversationItemData(item, itemAccountInfo, role); - } else if (listModelType_ == Type::CONVERSATION) { - auto& item = conversations_.at(index.row()); - return getConversationItemData(item, currentAccountInfo, role); } - } catch (const std::exception& e) { - qWarning() << e.what(); - } - return QVariant(); -} + if (role == Role::AccountId) { + return QVariant(itemAccountId); + } -QHash<int, QByteArray> -SmartListModel::roleNames() const -{ - QHash<int, QByteArray> roles; - roles[DisplayName] = "DisplayName"; - roles[DisplayID] = "DisplayID"; - roles[Presence] = "Presence"; - roles[URI] = "URI"; - roles[UnreadMessagesCount] = "UnreadMessagesCount"; - roles[LastInteractionDate] = "LastInteractionDate"; - roles[LastInteraction] = "LastInteraction"; - roles[ContactType] = "ContactType"; - roles[UID] = "UID"; - roles[InCall] = "InCall"; - roles[IsAudioOnly] = "IsAudioOnly"; - roles[CallStackViewShouldShow] = "CallStackViewShouldShow"; - roles[CallState] = "CallState"; - roles[SectionName] = "SectionName"; - roles[AccountId] = "AccountId"; - roles[Draft] = "Draft"; - roles[PictureUid] = "PictureUid"; - return roles; + auto& item = lrcInstance_->getConversationFromConvUid(itemConvUid, itemAccountId); + return dataForItem(item, role); + } break; + case Type::CONVERSATION: { + auto& item = conversations_.at(index.row()); + return dataForItem(item, role); + } break; + default: + break; + } + return {}; } void @@ -194,39 +164,6 @@ SmartListModel::fillConversationsList() endResetModel(); } -void -SmartListModel::updateContactAvatarUid(const QString& contactUri) -{ - contactAvatarUidMap_[contactUri] = Utils::generateUid(); -} - -void -SmartListModel::fillContactAvatarUidMap(const ContactModel::ContactInfoMap& contacts) -{ - if (contacts.size() == 0) { - contactAvatarUidMap_.clear(); - return; - } - - if (contactAvatarUidMap_.isEmpty() || contacts.size() != contactAvatarUidMap_.size()) { - bool useContacts = contacts.size() > contactAvatarUidMap_.size(); - auto contactsKeyList = contacts.keys(); - auto contactAvatarUidMapKeyList = contactAvatarUidMap_.keys(); - - for (int i = 0; - i < (useContacts ? contactsKeyList.size() : contactAvatarUidMapKeyList.size()); - ++i) { - // Insert or update - if (i < contactsKeyList.size() && !contactAvatarUidMap_.contains(contactsKeyList.at(i))) - contactAvatarUidMap_.insert(contactsKeyList.at(i), Utils::generateUid()); - // Remove - if (i < contactAvatarUidMapKeyList.size() - && !contacts.contains(contactAvatarUidMapKeyList.at(i))) - contactAvatarUidMap_.remove(contactAvatarUidMapKeyList.at(i)); - } - } -} - void SmartListModel::toggleSection(const QString& section) { @@ -251,140 +188,6 @@ SmartListModel::currentUidSmartListModelIndex() return -1; } -QVariant -SmartListModel::getConversationItemData(const conversation::Info& item, - const account::Info& accountInfo, - int role) const -{ - if (item.participants.size() <= 0) { - return QVariant(); - } - auto& contactModel = accountInfo.contactModel; - - // Since we are using image provider right now, image url representation should be unique to - // be able to use the image cache, account avatar will only be updated once PictureUid changed - switch (role) { - case Role::DisplayName: { - if (!item.participants.isEmpty()) - return QVariant(contactModel->bestNameForContact(item.participants[0])); - return QVariant(""); - } - case Role::DisplayID: { - if (!item.participants.isEmpty()) - return QVariant(contactModel->bestIdForContact(item.participants[0])); - return QVariant(""); - } - case Role::Presence: { - if (!item.participants.isEmpty()) { - auto& contact = contactModel->getContact(item.participants[0]); - return QVariant(contact.isPresent); - } - return QVariant(false); - } - case Role::PictureUid: { - if (!item.participants.isEmpty()) { - return QVariant(contactAvatarUidMap_[item.participants[0]]); - } - return QVariant(""); - } - case Role::URI: { - if (!item.participants.isEmpty()) { - return QVariant(item.participants[0]); - } - return QVariant(""); - } - case Role::UnreadMessagesCount: - return QVariant(item.unreadMessages); - case Role::LastInteractionDate: { - if (!item.interactions.empty()) { - auto& date = item.interactions.at(item.lastMessageUid).timestamp; - return QVariant(Utils::formatTimeString(date)); - } - return QVariant(""); - } - case Role::LastInteraction: { - if (!item.interactions.empty()) { - return QVariant(item.interactions.at(item.lastMessageUid).body); - } - return QVariant(""); - } - case Role::LastInteractionType: { - if (!item.interactions.empty()) { - return QVariant(static_cast<int>(item.interactions.at(item.lastMessageUid).type)); - } - return QVariant(0); - } - case Role::ContactType: { - if (!item.participants.isEmpty()) { - auto& contact = contactModel->getContact(item.participants[0]); - return QVariant(static_cast<int>(contact.profileInfo.type)); - } - return QVariant(0); - } - case Role::UID: - return QVariant(item.uid); - case Role::InCall: { - const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); - if (!convInfo.uid.isEmpty()) { - auto* callModel = lrcInstance_->getCurrentCallModel(); - return QVariant(callModel->hasCall(convInfo.callId)); - } - return QVariant(false); - } - case Role::IsAudioOnly: { - const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); - if (!convInfo.uid.isEmpty()) { - auto* call = lrcInstance_->getCallInfoForConversation(convInfo); - if (call) { - return QVariant(call->isAudioOnly); - } - } - return QVariant(); - } - case Role::CallStackViewShouldShow: { - const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); - if (!convInfo.uid.isEmpty() && !convInfo.callId.isEmpty()) { - auto* callModel = lrcInstance_->getCurrentCallModel(); - const auto& call = callModel->getCall(convInfo.callId); - return QVariant( - callModel->hasCall(convInfo.callId) - && ((!call.isOutgoing - && (call.status == lrc::api::call::Status::IN_PROGRESS - || call.status == lrc::api::call::Status::PAUSED - || call.status == lrc::api::call::Status::INCOMING_RINGING)) - || (call.isOutgoing && call.status != lrc::api::call::Status::ENDED))); - } - return QVariant(false); - } - case Role::CallState: { - const auto& convInfo = lrcInstance_->getConversationFromConvUid(item.uid); - if (!convInfo.uid.isEmpty()) { - if (auto* call = lrcInstance_->getCallInfoForConversation(convInfo)) { - return QVariant(static_cast<int>(call->status)); - } - } - return QVariant(); - } - case Role::SectionName: - return QVariant(QString()); - case Role::Draft: { - if (!item.uid.isEmpty()) { - const auto draft = lrcInstance_->getContentDraft(item.uid, accountInfo.id); - if (!draft.isEmpty()) { - /* - * Pencil Emoji - */ - uint cp = 0x270F; - auto emojiString = QString::fromUcs4(&cp, 1); - return emojiString + lrcInstance_->getContentDraft(item.uid, accountInfo.id); - } - } - return QVariant(""); - } - } - return QVariant(); -} - QModelIndex SmartListModel::index(int row, int column, const QModelIndex& parent) const { @@ -399,13 +202,6 @@ SmartListModel::index(int row, int column, const QModelIndex& parent) const return QModelIndex(); } -QModelIndex -SmartListModel::parent(const QModelIndex& child) const -{ - Q_UNUSED(child); - return QModelIndex(); -} - Qt::ItemFlags SmartListModel::flags(const QModelIndex& index) const { diff --git a/src/smartlistmodel.h b/src/smartlistmodel.h index 708c74558..f35a9e8d0 100644 --- a/src/smartlistmodel.h +++ b/src/smartlistmodel.h @@ -20,60 +20,32 @@ #pragma once -#include "abstractlistmodelbase.h" +#include "conversationlistmodelbase.h" + +namespace ContactList { +Q_NAMESPACE +enum Type { CONVERSATION, CONFERENCE, TRANSFER, COUNT__ }; +Q_ENUM_NS(Type) +} // namespace ContactList using namespace lrc::api; class LRCInstance; -class SmartListModel : public AbstractListModelBase +class SmartListModel : public ConversationListModelBase { Q_OBJECT public: - using AccountInfo = lrc::api::account::Info; - using ConversationInfo = lrc::api::conversation::Info; - using ContactInfo = lrc::api::contact::Info; - - enum class Type { CONVERSATION, CONFERENCE, TRANSFER, COUNT__ }; - - enum Role { - DisplayName = Qt::UserRole + 1, - DisplayID, - Presence, - URI, - UnreadMessagesCount, - LastInteractionDate, - LastInteraction, - LastInteractionType, - ContactType, - UID, - ContextMenuOpen, - InCall, - IsAudioOnly, - CallStackViewShouldShow, - CallState, - SectionName, - AccountId, - PictureUid, - Draft - }; - Q_ENUM(Role) + using Type = ContactList::Type; explicit SmartListModel(QObject* parent = nullptr, - SmartListModel::Type listModelType = Type::CONVERSATION, + Type listModelType = Type::CONVERSATION, LRCInstance* instance = nullptr); - ~SmartListModel(); - /* - * QAbstractListModel. - */ int rowCount(const QModelIndex& parent = QModelIndex()) const override; - int columnCount(const QModelIndex& parent) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash<int, QByteArray> roleNames() const override; QModelIndex index(int row, int column = 0, const QModelIndex& parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex& child) const override; Qt::ItemFlags flags(const QModelIndex& index) const override; Q_INVOKABLE void setConferenceableFilter(const QString& filter = {}); @@ -81,28 +53,9 @@ public: Q_INVOKABLE int currentUidSmartListModelIndex(); Q_INVOKABLE void fillConversationsList(); - /* - * This function is to update contact avatar uuid for current account when there's an contact - * avatar changed. - */ - Q_INVOKABLE void updateContactAvatarUid(const QString& contactUri); - private: - QVariant getConversationItemData(const ConversationInfo& item, - const AccountInfo& accountInfo, - int role) const; - - /* - * Give a uuid for each contact avatar for current account and it will serve PictureUid role - */ - void fillContactAvatarUidMap(const ContactModel::ContactInfoMap& contacts); - - /* - * List sectioning. - */ Type listModelType_; QMap<QString, bool> sectionState_; QMap<ConferenceableItem, ConferenceableValue> conferenceables_; - QMap<QString, QString> contactAvatarUidMap_; ConversationModel::ConversationQueueProxy conversations_; }; diff --git a/src/utils.cpp b/src/utils.cpp index 7c4c7d4ec..69434abf5 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -501,9 +501,9 @@ Utils::formatTimeString(const std::time_t& timeStamp) { auto currentTimeStamp = QDateTime::fromSecsSinceEpoch(timeStamp); auto now = QDateTime::currentDateTime(); - auto timeStampDMY = currentTimeStamp.toString("dd/MM/yyyy"); - if (timeStampDMY == now.toString("dd/MM/yyyy")) { - return currentTimeStamp.toString("hh:mm"); + auto timeStampDMY = currentTimeStamp.toString("dd/MM/yy"); + if (timeStampDMY == now.toString("dd/MM/yy")) { + return currentTimeStamp.toString("hhmm"); } else { return timeStampDMY; } diff --git a/src/utilsadapter.cpp b/src/utilsadapter.cpp index 84d900c5d..75b78cc7c 100644 --- a/src/utilsadapter.cpp +++ b/src/utilsadapter.cpp @@ -144,29 +144,6 @@ UtilsAdapter::getBestId(const QString& accountId, const QString& uid) return QString(); } -int -UtilsAdapter::getTotalUnreadMessages() -{ - int totalUnreadMessages {0}; - if (lrcInstance_->getCurrentAccountInfo().profileInfo.type != lrc::api::profile::Type::SIP) { - auto* convModel = lrcInstance_->getCurrentConversationModel(); - auto ringConversations = convModel->getFilteredConversations(lrc::api::profile::Type::RING, - false); - ringConversations.for_each( - [&totalUnreadMessages](const lrc::api::conversation::Info& conversation) { - totalUnreadMessages += conversation.unreadMessages; - }); - } - return totalUnreadMessages; -} - -int -UtilsAdapter::getTotalPendingRequest() -{ - auto& accountInfo = lrcInstance_->getCurrentAccountInfo(); - return accountInfo.contactModel->pendingRequestCount(); -} - void UtilsAdapter::setConversationFilter(const QString& filter) { diff --git a/src/utilsadapter.h b/src/utilsadapter.h index 50909b904..6fd23d379 100644 --- a/src/utilsadapter.h +++ b/src/utilsadapter.h @@ -49,8 +49,6 @@ public: Q_INVOKABLE QString GetRingtonePath(); Q_INVOKABLE bool checkStartupLink(); Q_INVOKABLE void setConversationFilter(const QString& filter); - Q_INVOKABLE int getTotalUnreadMessages(); - Q_INVOKABLE int getTotalPendingRequest(); Q_INVOKABLE const QString getBestName(const QString& accountId, const QString& uid); Q_INVOKABLE const QString getPeerUri(const QString& accountId, const QString& uid); Q_INVOKABLE QString getBestId(const QString& accountId); -- GitLab