diff --git a/CMakeLists.txt b/CMakeLists.txt
index 210340af4cc522c95c9730908b25d2ae1a54e197..10a49dce5b9bd738bcbc3c74c996d9a6c987f92e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -249,6 +249,7 @@ set(SRC_FILES
 	src/emoji/Provider.cpp
 
 	# Timeline
+	src/timeline/ReactionsModel.cpp
 	src/timeline/TimelineViewManager.cpp
 	src/timeline/TimelineModel.cpp
 	src/timeline/DelegateChooser.cpp
@@ -335,7 +336,7 @@ if(USE_BUNDLED_MTXCLIENT)
 	FetchContent_Declare(
 		MatrixClient
 		GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
-		GIT_TAG        v0.3.0
+		GIT_TAG        1893cd6171c40c250ca64d388c082789452340a8
 		)
 	FetchContent_MakeAvailable(MatrixClient)
 else()
@@ -451,6 +452,7 @@ qt5_wrap_cpp(MOC_HEADERS
 	src/emoji/PickButton.h
 
 	# Timeline
+	src/timeline/ReactionsModel.h
 	src/timeline/TimelineViewManager.h
 	src/timeline/TimelineModel.h
 	src/timeline/DelegateChooser.h
diff --git a/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json
index 00e9430f8fb5a9e5d075ed28745596fd07c875d3..fe3a4a257965ed95b90fb47f8da96658f4188667 100644
--- a/io.github.NhekoReborn.Nheko.json
+++ b/io.github.NhekoReborn.Nheko.json
@@ -146,9 +146,9 @@
       "name": "mtxclient",
       "sources": [
         {
-          "sha256": "0c2930b5861d93bab9a6515adca74ebaa78984119705d9b4372a9deb275dd30c",
+          "sha256": "a8c0239b7157fe8eadae8b06cd6c4e3531dcc61fc5a7f52dbb3c85106f70e3a5",
           "type": "archive",
-          "url": "https://github.com/Nheko-Reborn/mtxclient/archive/v0.3.0.tar.gz"
+          "url": "https://github.com/Nheko-Reborn/mtxclient/archive/1893cd6171c40c250ca64d388c082789452340a8.tar.gz"
         }
       ]
     },
diff --git a/resources/qml/Avatar.qml b/resources/qml/Avatar.qml
index b1007469afb0c47a1ca9d2f8b6a14061d2dd0a44..ed0652704cb6ecaa163793febbc5f969a6848caf 100644
--- a/resources/qml/Avatar.qml
+++ b/resources/qml/Avatar.qml
@@ -19,7 +19,7 @@ Rectangle {
 		verticalAlignment: Text.AlignVCenter
 		horizontalAlignment: Text.AlignHCenter
 		visible: img.status != Image.Ready
-		color: colors.brightText
+		color: colors.text
 	}
 
 	Image {
@@ -43,5 +43,5 @@ Rectangle {
 			}
 		}
 	}
-	color: colors.dark
+	color: colors.base
 }
diff --git a/resources/qml/ImageButton.qml b/resources/qml/ImageButton.qml
index dc576e183eb9aebddac9e912bc393a8118f92d82..dd10050352bc7bfe769945c6041c22f575d5df69 100644
--- a/resources/qml/ImageButton.qml
+++ b/resources/qml/ImageButton.qml
@@ -1,17 +1,11 @@
 import QtQuick 2.3
 import QtQuick.Controls 2.3
 
-Button {
+AbstractButton {
 	property string image: undefined
 
 	id: button
 
-	flat: true
-
-	// disable background, because we don't want a border on hover
-	background: Item {
-	}
-
 	Image {
 		id: buttonImg
 		// Workaround, can't get icon.source working for now...
diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml
new file mode 100644
index 0000000000000000000000000000000000000000..cb15b7238b7f66b9b11ec8f5211cb3492439c847
--- /dev/null
+++ b/resources/qml/Reactions.qml
@@ -0,0 +1,76 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+
+Flow {
+	anchors.left: parent.left
+	anchors.right: parent.right
+	spacing: 4
+
+	property alias reactions: repeater.model
+
+	Repeater {
+		id: repeater
+
+		AbstractButton {
+			id: reaction
+			text: model.key
+			hoverEnabled: true
+			implicitWidth: contentItem.childrenRect.width + contentItem.leftPadding*2
+			implicitHeight: contentItem.childrenRect.height
+
+			ToolTip.visible: hovered
+			ToolTip.text: model.users
+
+
+			contentItem: Row {
+				anchors.centerIn: parent
+				spacing: reactionText.implicitHeight/4
+				leftPadding: reactionText.implicitHeight / 2
+				rightPadding: reactionText.implicitHeight / 2
+
+				TextMetrics {
+					id: textMetrics
+					font.family: settings.emoji_font_family
+					elide: Text.ElideRight
+					elideWidth: 150
+					text: reaction.text
+				}
+
+				Text {
+					anchors.baseline: reactionCounter.baseline
+					id: reactionText
+					text: textMetrics.elidedText + (textMetrics.elidedText == textMetrics.text ? "" : "…")
+					font.family: settings.emoji_font_family
+					color: reaction.hovered ? colors.highlight : colors.text
+					maximumLineCount: 1
+				}
+
+				Rectangle {
+					id: divider
+					height: reactionCounter.implicitHeight * 1.4
+					width: 1
+					color: reaction.hovered ? colors.highlight : colors.text
+				}
+
+				Text {
+					anchors.verticalCenter: divider.verticalCenter
+					id: reactionCounter
+					text: model.counter
+					font: reaction.font
+					color: reaction.hovered ? colors.highlight : colors.text
+				}
+			}
+
+			background: Rectangle {
+				anchors.centerIn: parent
+				implicitWidth: reaction.implicitWidth
+				implicitHeight: reaction.implicitHeight
+				border.color: (reaction.hovered || model.selfReacted )? colors.highlight : colors.text
+				color: colors.base
+				border.width: 1
+				radius: reaction.height / 2.0
+			}
+		}
+	}
+}
+
diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml
index 05c6911291e44bc61cac43401b23b04a6a143f49..22222ef3db4b7558021f2767bbb6120b80c54585 100644
--- a/resources/qml/TimelineRow.qml
+++ b/resources/qml/TimelineRow.qml
@@ -52,6 +52,10 @@ MouseArea {
 
 				modelData: model
 			}
+
+			Reactions {
+				reactions: model.reactions
+			}
 		}
 
 		StatusIndicator {
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index 997f901e434d55224c81bfa6652df3befe65c8bd..eca646d1d88e2577b4b7f4c4af7e5163be5ab840 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -25,6 +25,7 @@ Page {
 		id: settings
 		category: "user"
 		property bool avatar_circles: true
+		property string emoji_font_family: "default"
 	}
 
 	Settings {
@@ -133,6 +134,21 @@ Page {
 				sequence: StandardKey.MoveToNextPage
 				onActivated: { chat.contentY = chat.contentY + chat.height / 2; chat.returnToBounds(); }
 			}
+			Shortcut {
+				sequence: StandardKey.Cancel
+				onActivated: chat.model.reply = undefined
+			}
+			Shortcut {
+				sequence: "Alt+Up"
+				onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply? chat.model.idToIndex(chat.model.reply) + 1 : 0)
+			}
+			Shortcut {
+				sequence: "Alt+Down"
+				onActivated: {
+					var idx = chat.model.reply? chat.model.idToIndex(chat.model.reply) - 1 : -1
+					chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : undefined
+				}
+			}
 
 			ScrollBar.vertical: ScrollBar {
 				id: scrollbar
@@ -210,7 +226,7 @@ Page {
 						anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
 						visible: section.includes(" ")
 						text: chat.model.formatDateSeparator(modelData.timestamp)
-						color: colors.brightText
+						color: colors.text
 
                         height: fontMetrics.height * 1.4
                         width: contentWidth * 1.2
@@ -218,7 +234,7 @@ Page {
                         horizontalAlignment: Text.AlignHCenter
 						background: Rectangle {
 							radius: parent.height / 2
-							color: colors.dark
+							color: colors.base
 						}
 					}
 					Row {
diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml
index 1b1be34510bd95a990006a75fd9ed7686d4af77c..90013de93b349b2917f1675893afec91b3d91967 100644
--- a/resources/qml/delegates/Reply.qml
+++ b/resources/qml/delegates/Reply.qml
@@ -15,7 +15,7 @@ Item {
 	MouseArea {
 		anchors.fill: parent
 		preventStealing: true
-		onClicked: chat.positionViewAtIndex(chat.model.idToIndex(timelineManager.replyingEvent), ListView.Contain)
+		onClicked: chat.positionViewAtIndex(chat.model.idToIndex(modelData.id), ListView.Contain)
 		cursorShape: Qt.PointingHandCursor
 	}
 
diff --git a/resources/res.qrc b/resources/res.qrc
index c6daaa80198b5aff493193a90ded74c9fc9336fd..64a5b3cbe651268d6459ca4e46405db85af692ae 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -117,8 +117,9 @@
         <file>qml/MatrixText.qml</file>
         <file>qml/StatusIndicator.qml</file>
         <file>qml/EncryptionIndicator.qml</file>
-        <file>qml/TimelineRow.qml</file>
+        <file>qml/Reactions.qml</file>
         <file>qml/ScrollHelper.qml</file>
+        <file>qml/TimelineRow.qml</file>
         <file>qml/delegates/MessageDelegate.qml</file>
         <file>qml/delegates/TextMessage.qml</file>
         <file>qml/delegates/NoticeMessage.qml</file>
diff --git a/resources/styles/system.qss b/resources/styles/system.qss
index dd2a90efa95685044f619879fa3b522f3a84903e..01951affcff00b58f22a9070f749139a2bfa758d 100644
--- a/resources/styles/system.qss
+++ b/resources/styles/system.qss
@@ -98,15 +98,15 @@ UserMentionsWidget {
     qproperty-highlightedTitleColor: palette(highlighted-text);
     qproperty-highlightedSubtitleColor: palette(highlighted-text);
 
-    qproperty-hoverTitleColor: palette(highlightedtext);
-    qproperty-hoverSubtitleColor: palette(highlightedtext);
+    qproperty-hoverTitleColor: palette(dark);
+    qproperty-hoverSubtitleColor: palette(dark);
 
     qproperty-btnColor: palette(dark);
     qproperty-btnTextColor: palette(bright-text);
 
     qproperty-timestampColor: palette(text);
     qproperty-highlightedTimestampColor: palette(highlighted-text);
-    qproperty-hoverTimestampColor: palette(highlighted-text);
+    qproperty-hoverTimestampColor: palette(dark);
 
     qproperty-bubbleBgColor: palette(base);
     qproperty-bubbleFgColor: palette(text);
diff --git a/src/Olm.cpp b/src/Olm.cpp
index c8e4c13ceff3a9f5d7c22a0a4adeb9c38b631110..8ea395660376e3cc2f71a9d42653e27174354f88 100644
--- a/src/Olm.cpp
+++ b/src/Olm.cpp
@@ -164,8 +164,8 @@ encrypt_group_message(const std::string &room_id, const std::string &device_id,
         using namespace mtx::events;
 
         // relations shouldn't be encrypted...
-        mtx::common::RelatesTo relation;
-        if (body["content"].count("m.relates_to") != 0) {
+        mtx::common::ReplyRelatesTo relation;
+        if (body["content"]["m.relates_to"].contains("m.in_reply_to")) {
                 relation = body["content"]["m.relates_to"];
                 body["content"].erase("m.relates_to");
         }
diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 54bce52c0e59dbabc1fa76eaae55cdecf3cbd883..e19aa876b1c5e249b4675554c50f642a99961d55 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -115,7 +115,7 @@ UserSettings::applyTheme()
                   /*mid*/ QColor(110, 110, 110),
                   /*text*/ QColor("#333"),
                   /*bright_text*/ QColor("#333"),
-                  /*base*/ QColor("white"),
+                  /*base*/ QColor("#eee"),
                   /*window*/ QColor("white"));
                 lightActive.setColor(QPalette::Highlight, QColor("#38a3d8"));
                 lightActive.setColor(QPalette::ToolTipBase, lightActive.base().color());
diff --git a/src/timeline/ReactionsModel.cpp b/src/timeline/ReactionsModel.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2e249819b54aaaaf871ca88378560d14297c6892
--- /dev/null
+++ b/src/timeline/ReactionsModel.cpp
@@ -0,0 +1,98 @@
+#include "ReactionsModel.h"
+
+#include <Cache.h>
+#include <MatrixClient.h>
+
+QHash<int, QByteArray>
+ReactionsModel::roleNames() const
+{
+        return {
+          {Key, "key"},
+          {Count, "counter"},
+          {Users, "users"},
+          {SelfReacted, "selfReacted"},
+        };
+}
+
+int
+ReactionsModel::rowCount(const QModelIndex &) const
+{
+        return static_cast<int>(reactions.size());
+}
+
+QVariant
+ReactionsModel::data(const QModelIndex &index, int role) const
+{
+        const int i = index.row();
+        if (i < 0 || i >= static_cast<int>(reactions.size()))
+                return {};
+
+        switch (role) {
+        case Key:
+                return QString::fromStdString(reactions[i].key);
+        case Count:
+                return static_cast<int>(reactions[i].reactions.size());
+        case Users: {
+                QString users;
+                bool first = true;
+                for (const auto &reaction : reactions[i].reactions) {
+                        if (!first)
+                                users += ", ";
+                        else
+                                first = false;
+                        users += QString::fromStdString(
+                          cache::displayName(room_id_, reaction.second.sender));
+                }
+                return users;
+        }
+        case SelfReacted:
+                for (const auto &reaction : reactions[i].reactions)
+                        if (reaction.second.sender == http::client()->user_id().to_string())
+                                return true;
+                return false;
+        default:
+                return {};
+        }
+}
+
+void
+ReactionsModel::addReaction(const std::string &room_id,
+                            const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction)
+{
+        room_id_ = room_id;
+
+        int idx = 0;
+        for (auto &storedReactions : reactions) {
+                if (storedReactions.key == reaction.content.relates_to.key) {
+                        storedReactions.reactions[reaction.event_id] = reaction;
+                        emit dataChanged(index(idx, 0), index(idx, 0));
+                        return;
+                }
+                idx++;
+        }
+
+        beginInsertRows(QModelIndex(), idx, idx);
+        reactions.push_back(
+          KeyReaction{reaction.content.relates_to.key, {{reaction.event_id, reaction}}});
+        endInsertRows();
+}
+
+void
+ReactionsModel::removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction)
+{
+        int idx = 0;
+        for (auto &storedReactions : reactions) {
+                if (storedReactions.key == reaction.content.relates_to.key) {
+                        storedReactions.reactions.erase(reaction.event_id);
+
+                        if (storedReactions.reactions.size() == 0) {
+                                beginRemoveRows(QModelIndex(), idx, idx);
+                                reactions.erase(reactions.begin() + idx);
+                                endRemoveRows();
+                        } else
+                                emit dataChanged(index(idx, 0), index(idx, 0));
+                        return;
+                }
+                idx++;
+        }
+}
diff --git a/src/timeline/ReactionsModel.h b/src/timeline/ReactionsModel.h
new file mode 100644
index 0000000000000000000000000000000000000000..5f61cd424debc78e15e26fbb545482eb5bbaad74
--- /dev/null
+++ b/src/timeline/ReactionsModel.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <QAbstractListModel>
+#include <QHash>
+
+#include <utility>
+#include <vector>
+
+#include <mtx/events/collections.hpp>
+
+class ReactionsModel : public QAbstractListModel
+{
+        Q_OBJECT
+public:
+        explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); }
+        enum Roles
+        {
+                Key,
+                Count,
+                Users,
+                SelfReacted,
+        };
+
+        QHash<int, QByteArray> roleNames() const override;
+        int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+        QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+public slots:
+        void addReaction(const std::string &room_id,
+                         const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
+        void removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
+
+private:
+        struct KeyReaction
+        {
+                std::string key;
+                std::map<std::string, mtx::events::RoomEvent<mtx::events::msg::Reaction>> reactions;
+        };
+        std::string room_id_;
+        std::vector<KeyReaction> reactions;
+};
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index df2051e66dd11e1c12f58d7500902f4d32535729..388a58428968e0d8b721935db2393fe4b099e230 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -42,6 +42,8 @@ struct RoomEventType
                 switch (e.type) {
                 case EventType::RoomKeyRequest:
                         return qml_mtx_events::EventType::KeyRequest;
+                case EventType::Reaction:
+                        return qml_mtx_events::EventType::Reaction;
                 case EventType::RoomAliases:
                         return qml_mtx_events::EventType::Aliases;
                 case EventType::RoomAvatar:
@@ -223,6 +225,7 @@ TimelineModel::roleNames() const
           {State, "state"},
           {IsEncrypted, "isEncrypted"},
           {ReplyTo, "replyTo"},
+          {Reactions, "reactions"},
           {RoomId, "roomId"},
           {RoomName, "roomName"},
           {RoomTopic, "roomTopic"},
@@ -345,6 +348,11 @@ TimelineModel::data(const QString &id, int role) const
         }
         case ReplyTo:
                 return QVariant(QString::fromStdString(in_reply_to_event(event)));
+        case Reactions:
+                if (reactions.count(id))
+                        return QVariant::fromValue((QObject *)&reactions.at(id));
+                else
+                        return {};
         case RoomId:
                 return QVariant(QString::fromStdString(room_id(event)));
         case RoomName:
@@ -471,7 +479,6 @@ TimelineModel::fetchMore(const QModelIndex &)
                                               mtx::errors::to_string(err->matrix_error.errcode),
                                               err->matrix_error.error,
                                               err->parse_error);
-                          emit oldMessagesRetrieved(std::move(res));
                           setPaginationInProgress(false);
                           return;
                   }
@@ -609,6 +616,18 @@ TimelineModel::internalAddEvents(
                         QString redacts = QString::fromStdString(redaction->redacts);
                         auto redacted   = std::find(eventOrder.begin(), eventOrder.end(), redacts);
 
+                        auto event = events.value(redacts);
+                        if (auto reaction =
+                              std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
+                                &event)) {
+                                QString reactedTo =
+                                  QString::fromStdString(reaction->content.relates_to.event_id);
+                                reactions[reactedTo].removeReaction(*reaction);
+                                int idx = idToIndex(reactedTo);
+                                if (idx >= 0)
+                                        emit dataChanged(index(idx, 0), index(idx, 0));
+                        }
+
                         if (redacted != eventOrder.end()) {
                                 auto redactedEvent = std::visit(
                                   [](const auto &ev)
@@ -632,6 +651,18 @@ TimelineModel::internalAddEvents(
                         continue; // don't insert redaction into timeline
                 }
 
+                if (auto reaction =
+                      std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&e)) {
+                        QString reactedTo =
+                          QString::fromStdString(reaction->content.relates_to.event_id);
+                        events.insert(id, e);
+                        reactions[reactedTo].addReaction(room_id_.toStdString(), *reaction);
+                        int idx = idToIndex(reactedTo);
+                        if (idx >= 0)
+                                emit dataChanged(index(idx, 0), index(idx, 0));
+                        continue; // don't insert reaction into timeline
+                }
+
                 if (auto event =
                       std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&e)) {
                         auto e_      = decryptEvent(*event).event;
@@ -707,6 +738,11 @@ TimelineModel::addBackwardsEvents(const mtx::responses::Messages &msgs)
         }
 
         prev_batch_token_ = QString::fromStdString(msgs.end);
+
+        if (ids.empty() && !msgs.chunk.empty()) {
+                // no visible events fetched, prevent loading from stopping
+                fetchMore(QModelIndex());
+        }
 }
 
 QString
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index cc63eca29fdd92d42d62ca6fdea88c8cfbb8531d..a737aac7354d9c35748eaea57d35d3a3ed6e1a34 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -9,6 +9,7 @@
 #include <mtxclient/http/errors.hpp>
 
 #include "CacheCryptoStructs.h"
+#include "ReactionsModel.h"
 
 namespace mtx::http {
 using RequestErr = const std::optional<mtx::http::ClientError> &;
@@ -29,6 +30,8 @@ enum EventType
         Unsupported,
         /// m.room_key_request
         KeyRequest,
+        /// m.reaction,
+        Reaction,
         /// m.room.aliases
         Aliases,
         /// m.room.avatar
@@ -155,6 +158,7 @@ public:
                 State,
                 IsEncrypted,
                 ReplyTo,
+                Reactions,
                 RoomId,
                 RoomName,
                 RoomTopic,
@@ -271,6 +275,7 @@ private:
         QSet<QString> read;
         QList<QString> pending;
         std::vector<QString> eventOrder;
+        std::map<QString, ReactionsModel> reactions;
 
         QString room_id_;
         QString prev_batch_token_;