From 3185df08374f7b6aa26f83dc382c0be2f8e61727 Mon Sep 17 00:00:00 2001 From: Ming Rui Zhang <mingrui.zhang@savoirfairelinux.com> Date: Fri, 8 Oct 2021 17:27:43 -0400 Subject: [PATCH] messageListView: add typing indicator Gitlab: #552 Change-Id: I0a4dc3b61a22aafb40d8a301033c59d2cc02bc79 --- qml.qrc | 1 + src/commoncomponents/TypingDots.qml | 107 ++++++++++++++++++ src/constant/JamiQmlUtils.qml | 4 +- src/constant/JamiStrings.qml | 4 + src/constant/JamiTheme.qml | 9 ++ src/mainview/components/MessageBar.qml | 1 + src/mainview/components/MessageListView.qml | 80 +++++++++++++ .../components/ScrollToBottomButton.qml | 3 +- src/messagesadapter.cpp | 23 ++++ src/messagesadapter.h | 4 + 10 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/commoncomponents/TypingDots.qml diff --git a/qml.qrc b/qml.qrc index 135bfadb4..5abf8aece 100644 --- a/qml.qrc +++ b/qml.qrc @@ -169,5 +169,6 @@ <file>src/commoncomponents/GeneratedMessageDelegate.qml</file> <file>src/commoncomponents/DataTransferMessageDelegate.qml</file> <file>src/mainview/components/ScrollToBottomButton.qml</file> + <file>src/commoncomponents/TypingDots.qml</file> </qresource> </RCC> diff --git a/src/commoncomponents/TypingDots.qml b/src/commoncomponents/TypingDots.qml new file mode 100644 index 000000000..939f4193b --- /dev/null +++ b/src/commoncomponents/TypingDots.qml @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2021 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.15 +import QtQuick.Controls 2.15 +import QtGraphicalEffects 1.15 + +import net.jami.Constants 1.1 + +Row { + id: root + + property int currentRect: 0 + + spacing: 5 + + Timer { + repeat: true + running: true + interval: JamiTheme.typingDotsAnimationInterval + + onTriggered: { + if (root.currentRect < 2) + root.currentRect ++ + else + root.currentRect = 0 + } + } + + Repeater { + model: 3 + + Rectangle { + id: circleRect + + radius: JamiTheme.typingDotsRadius + + width: JamiTheme.typingDotsSize + height: JamiTheme.typingDotsSize + color: JamiTheme.typingDotsNormalColor + + states: State { + id: enlargeState + + name: "enlarge" + when: root.currentRect === index + } + + transitions: [ + Transition { + to: "enlarge" + ParallelAnimation { + NumberAnimation { + from: 1.0 + to: 1.3 + target: circleRect + duration: JamiTheme.typingDotsAnimationInterval + property: "scale" + } + + ColorAnimation { + from: JamiTheme.typingDotsNormalColor + to: JamiTheme.typingDotsEnlargeColor + target: circleRect + property: "color" + duration: JamiTheme.typingDotsAnimationInterval + } + } + }, + Transition { + from: "enlarge" + ParallelAnimation { + NumberAnimation { + from: 1.3 + to: 1.0 + target: circleRect + duration: JamiTheme.typingDotsAnimationInterval + property: "scale" + } + ColorAnimation { + from: JamiTheme.typingDotsEnlargeColor + to: JamiTheme.typingDotsNormalColor + target: circleRect + property: "color" + duration: JamiTheme.typingDotsAnimationInterval + } + } + } + ] + } + } +} diff --git a/src/constant/JamiQmlUtils.qml b/src/constant/JamiQmlUtils.qml index 0a82a6e37..d0c02d5b7 100644 --- a/src/constant/JamiQmlUtils.qml +++ b/src/constant/JamiQmlUtils.qml @@ -79,7 +79,7 @@ Item { } } - TextMetrics { + Text { id: globalTextMetrics } @@ -87,6 +87,6 @@ Item { globalTextMetrics.font = font globalTextMetrics.text = text - return globalTextMetrics.boundingRect + return Qt.size(globalTextMetrics.contentWidth, globalTextMetrics.contentHeight) } } diff --git a/src/constant/JamiStrings.qml b/src/constant/JamiStrings.qml index cefd63286..5725172a9 100644 --- a/src/constant/JamiStrings.qml +++ b/src/constant/JamiStrings.qml @@ -257,6 +257,10 @@ Item { // Chatview footer property string jumpToLatest: qsTr("Jump to latest") + property string typeIndicatorSingle: qsTr("{} is typing…") + property string typeIndicatorPlural: qsTr("{} are typing…") + property string typeIndicatorMax: qsTr("Several people are typing…") + property string typeIndicatorAnd: qsTr(" and ") // ConnectToAccountManager property string enterJAMSURL: qsTr("Enter Jami Account Management Server (JAMS) URL") diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml index 0f2b08147..79ff8055d 100644 --- a/src/constant/JamiTheme.qml +++ b/src/constant/JamiTheme.qml @@ -172,6 +172,10 @@ Item { // Files To Send Container property color removeFileButtonColor: Qt.rgba(96, 95, 97, 0.5) + // TypingDots + property color typingDotsNormalColor: darkTheme ? "#686b72" : "lightgrey" + property color typingDotsEnlargeColor: darkTheme ? "white" : Qt.darker("lightgrey", 3.0) + // Font. property color faddedFontColor: darkTheme? "#c0c0c0" : "#a0a0a0" property color faddedLastInteractionFontColor: darkTheme ? "#c0c0c0" : "#505050" @@ -284,6 +288,11 @@ Item { property real chatViewFooterTextAreaMaximumHeight: 130 property real chatViewScrollToBottomButtonBottomMargin: 8 + // TypingDots + property real typingDotsAnimationInterval: 500 + property real typingDotsRadius: 30 + property real typingDotsSize: 8 + // MessageWebView File Transfer Container property real filesToSendContainerSpacing: 5 property real filesToSendContainerPadding: 10 diff --git a/src/mainview/components/MessageBar.qml b/src/mainview/components/MessageBar.qml index b9a8c8628..cf5aed3b8 100644 --- a/src/mainview/components/MessageBar.qml +++ b/src/mainview/components/MessageBar.qml @@ -151,6 +151,7 @@ ColumnLayout { - marginSize / 2 onSendMessagesRequired: root.sendMessageButtonClicked() + onTextChanged: MessagesAdapter.userIsComposing(text ? true : false) } PushButton { diff --git a/src/mainview/components/MessageListView.qml b/src/mainview/components/MessageListView.qml index cd7ff3c3e..5d515994d 100644 --- a/src/mainview/components/MessageListView.qml +++ b/src/mainview/components/MessageListView.qml @@ -273,4 +273,84 @@ ListView { onClicked: root.ScrollBar.vertical.position = 1.0 - root.ScrollBar.vertical.size } + + header: Control { + id: typeIndicatorContainer + + topPadding: 3 + + width: root.width + height: typeIndicatorNameText.contentHeight + topPadding + + visible: MessagesAdapter.currentConvComposingList.length + + TypingDots { + id: typingDots + + anchors.left: typeIndicatorContainer.left + anchors.leftMargin: 5 + anchors.verticalCenter: typeIndicatorContainer.verticalCenter + } + + Text { + id: typeIndicatorNameText + + anchors.left: typingDots.right + anchors.leftMargin: 5 + anchors.verticalCenter: typeIndicatorContainer.verticalCenter + + width: { + var textSize = text ? JamiQmlUtils.getTextBoundingRect(font, text).width : 0 + var typingContentWidth = typingDots.width + typingDots.anchors.leftMargin + + typeIndicatorNameText.anchors.leftMargin + + typeIndicatorEndingText.contentWidth + return Math.min(typeIndicatorContainer.width - 5 - typingContentWidth, textSize) + } + + font.pointSize: 8 + font.bold: Font.DemiBold + elide: Text.ElideRight + color: JamiTheme.textColor + text: { + var finalText = "" + var nameList = MessagesAdapter.currentConvComposingList + + if (nameList.length > 4) + return "" + if (nameList.length === 1) + return nameList[0] + + for (var i = 0; i < nameList.length; i++) { + finalText += nameList[i] + + if (i === nameList.length - 2) + finalText += JamiStrings.typeIndicatorAnd + else if (i !== nameList.length - 1) + finalText += ", " + } + + return finalText + } + } + + Text { + id: typeIndicatorEndingText + + anchors.left: typeIndicatorNameText.right + anchors.verticalCenter: typeIndicatorContainer.verticalCenter + + font.pointSize: 8 + color: JamiTheme.textColor + text: { + var nameList = MessagesAdapter.currentConvComposingList + + if (nameList.length > 4) + return JamiStrings.typeIndicatorMax + if (nameList.length === 1) + return JamiStrings.typeIndicatorSingle.replace("{}", "") + + return JamiStrings.typeIndicatorPlural.replace("{}", "") + } + } + } } diff --git a/src/mainview/components/ScrollToBottomButton.qml b/src/mainview/components/ScrollToBottomButton.qml index cb9b10892..7eae256bb 100644 --- a/src/mainview/components/ScrollToBottomButton.qml +++ b/src/mainview/components/ScrollToBottomButton.qml @@ -111,7 +111,8 @@ Control { MouseArea { anchors.fill: parent - cursorShape: Qt.PointingHandCursor + cursorShape: root.opacity ? Qt.PointingHandCursor : + Qt.ArrowCursor onClicked: root.clicked() } diff --git a/src/messagesadapter.cpp b/src/messagesadapter.cpp index 15e33daa7..2d0875c5a 100644 --- a/src/messagesadapter.cpp +++ b/src/messagesadapter.cpp @@ -54,6 +54,7 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager, const auto& conversation = lrcInstance_->getConversationFromConvUid(convId); filteredMsgListModel_->setSourceModel(conversation.interactions.get()); set_messageListModel(QVariant::fromValue(filteredMsgListModel_)); + set_currentConvComposingList({}); }); connect(previewEngine_, &PreviewEngine::infoReady, this, &MessagesAdapter::onPreviewInfoReady); @@ -113,6 +114,12 @@ MessagesAdapter::connectConversationModel() this, &MessagesAdapter::onConversationMessagesLoaded, Qt::UniqueConnection); + + QObject::connect(currentConversationModel, + &ConversationModel::composingStatusChanged, + this, + &MessagesAdapter::onComposingStatusChanged, + Qt::UniqueConnection); } void @@ -414,6 +421,22 @@ MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& lin conversation.interactions->linkifyMessage(messageId, linkified); } +void +MessagesAdapter::onComposingStatusChanged(const QString& convId, + const QString& contactUri, + bool isComposing) +{ + if (lrcInstance_->get_selectedConvUid() == convId) { + auto name = lrcInstance_->getCurrentContactModel()->bestNameForContact(contactUri); + if (isComposing) + currentConvComposingList_.append(name); + else + currentConvComposingList_.removeOne(name); + + Q_EMIT currentConvComposingListChanged(); + } +} + bool MessagesAdapter::isLocalImage(const QString& msg) { diff --git a/src/messagesadapter.h b/src/messagesadapter.h index 062743332..aec4f65d9 100644 --- a/src/messagesadapter.h +++ b/src/messagesadapter.h @@ -60,6 +60,7 @@ class MessagesAdapter final : public QmlAdapterBase { Q_OBJECT QML_RO_PROPERTY(QVariant, messageListModel) + QML_RO_PROPERTY(QList<QString>, currentConvComposingList) public: explicit MessagesAdapter(AppSettingsManager* settingsManager, @@ -121,6 +122,9 @@ private Q_SLOTS: void onPreviewInfoReady(QString messageIndex, QVariantMap urlInMessage); void onConversationMessagesLoaded(uint32_t requestId, const QString& convId); void onMessageLinkified(const QString& messageId, const QString& linkified); + void onComposingStatusChanged(const QString& convId, + const QString& contactUri, + bool isComposing); private: AppSettingsManager* settingsManager_; -- GitLab