diff --git a/qml.qrc b/qml.qrc
index 135bfadb4030160a465330951e3ceda90801a9a8..5abf8aece942161856e68c2ebd2157f3e1fe509c 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 0000000000000000000000000000000000000000..939f4193b2ad62f8b23e457c34b21a78a4b96c4b
--- /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 0a82a6e37f66a4b94ca7c9a3076877ef0d4ca270..d0c02d5b7f5eca22e25a9c8d8de02f5739363b3c 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 cefd63286d038c2f91aaf955a57160838717a7c3..5725172a9d94c0a5f8fc29a16a3a66e658d0eb29 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 0f2b081479bd81ffc8dfc0961179d2709dd7e942..79ff8055d13d4a3e447fd9cd75821eff7125522d 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 b9a8c862841b6d213941db5a5b0998dd29bd609a..cf5aed3b804ecd4dc4879a30d800cce14b2b40e6 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 cd7ff3c3e07da8d06a845b1604a79aba1cd11a75..5d515994ddded4b69230bf020a3f563a481b4069 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 cb9b10892fc04f1e49c2a9e9028bb1efd0c9a4b1..7eae256bb8e2905d66cacd62ced6b3dbb791629c 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 15e33daa796788ce7a8419378e70e000e219069a..2d0875c5ab27b4077092112d4a53925fc5592a80 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 0627433328c02071cd177986f524910c647846fc..aec4f65d983c35efbc4c626a1578efb87925d851 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_;