diff --git a/CMakeLists.txt b/CMakeLists.txt index 1be11fa3..7295cc54 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -252,7 +252,8 @@ set(SRC_FILES # Timeline - src/timeline/ReactionsModel.cpp + src/timeline/EventStore.cpp + src/timeline/Reaction.cpp src/timeline/TimelineViewManager.cpp src/timeline/TimelineModel.cpp src/timeline/DelegateChooser.cpp @@ -340,7 +341,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47 + GIT_TAG d8666a3f1a5b709b78ccea2b545d540a8cb502ca ) FetchContent_MakeAvailable(MatrixClient) else() @@ -463,7 +464,8 @@ qt5_wrap_cpp(MOC_HEADERS src/emoji/Provider.h # Timeline - src/timeline/ReactionsModel.h + src/timeline/EventStore.h + src/timeline/Reaction.h src/timeline/TimelineViewManager.h src/timeline/TimelineModel.h src/timeline/DelegateChooser.h diff --git a/README.md b/README.md index 20340a46..2d24165c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ sudo eselect repository enable matrix sudo emerge -a nheko ``` +#### Nix(os) + +```bash +nix-env -iA nixpkgs.nheko +# or +nix-shell -p nheko --run nheko +``` + #### Alpine Linux (and postmarketOS) Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS. @@ -124,6 +132,7 @@ Nheko can use bundled version for most of those libraries automatically, if the To use them, you can enable the hunter integration by passing `-DHUNTER_ENABLED=ON`. It is probably wise to link those dependencies statically by passing `-DBUILD_SHARED_LIBS=OFF` You can select which bundled dependencies you want to use py passing various `-DUSE_BUNDLED_*` flags. By default all dependencies are bundled *if* you enable hunter. +If you experience build issues and you are trying to link `mtxclient` library without hunter, make sure the library version(commit) as mentioned in the `CMakeList.txt` is used. Sometimes we have to make breaking changes in `mtxclient` and for that period the master branch of both repos may not be compatible. The bundle flags are currently: diff --git a/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json index 9bebdb61..b11e587c 100644 --- a/io.github.NhekoReborn.Nheko.json +++ b/io.github.NhekoReborn.Nheko.json @@ -146,7 +146,7 @@ "name": "mtxclient", "sources": [ { - "commit": "744018c86a8094acbda9821d6d7b5a890d4aac47", + "commit": "d8666a3f1a5b709b78ccea2b545d540a8cb502ca", "type": "git", "url": "https://github.com/Nheko-Reborn/mtxclient.git" } diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml index d56143dd..1da223d5 100644 --- a/resources/qml/MatrixText.qml +++ b/resources/qml/MatrixText.qml @@ -6,6 +6,7 @@ TextEdit { readOnly: true wrapMode: Text.Wrap selectByMouse: true + activeFocusOnPress: false color: colors.text onLinkActivated: { diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml index c06dc826..c1091756 100644 --- a/resources/qml/Reactions.qml +++ b/resources/qml/Reactions.qml @@ -30,11 +30,11 @@ Flow { implicitHeight: contentItem.childrenRect.height ToolTip.visible: hovered - ToolTip.text: model.users + ToolTip.text: modelData.users onClicked: { - console.debug("Picked " + model.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + model.selfReactedEvent) - timelineManager.reactToMessage(reactionFlow.roomId, reactionFlow.eventId, model.key, model.selfReactedEvent) + console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + modelData.selfReactedEvent) + timelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key) } @@ -49,13 +49,13 @@ Flow { font.family: settings.emojiFont elide: Text.ElideRight elideWidth: 150 - text: model.key + text: modelData.key } Text { anchors.baseline: reactionCounter.baseline id: reactionText - text: textMetrics.elidedText + (textMetrics.elidedText == model.key ? "" : "…") + text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…") font.family: settings.emojiFont color: reaction.hovered ? colors.highlight : colors.text maximumLineCount: 1 @@ -65,13 +65,13 @@ Flow { id: divider height: Math.floor(reactionCounter.implicitHeight * 1.4) width: 1 - color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text + color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text } Text { anchors.verticalCenter: divider.verticalCenter id: reactionCounter - text: model.counter + text: modelData.count font: reaction.font color: reaction.hovered ? colors.highlight : colors.text } @@ -82,8 +82,8 @@ Flow { implicitWidth: reaction.implicitWidth implicitHeight: reaction.implicitHeight - border.color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text - color: model.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base + border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text + color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base border.width: 1 radius: reaction.height / 2.0 } diff --git a/resources/qml/ScrollHelper.qml b/resources/qml/ScrollHelper.qml index e72fcbd2..ba7c2648 100644 --- a/resources/qml/ScrollHelper.qml +++ b/resources/qml/ScrollHelper.qml @@ -106,6 +106,6 @@ MouseArea { //How long the scrollbar will remain visible interval: 500 // Hide the scrollbars - onTriggered: flickable.cancelFlick(); + onTriggered: { flickable.cancelFlick(); flickable.movementEnded(); } } } diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml index dfee62dc..a38a4d34 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml @@ -8,22 +8,25 @@ import im.nheko 1.0 import "./delegates" import "./emoji" -MouseArea { +Item { anchors.left: parent.left anchors.right: parent.right height: row.height - propagateComposedEvents: true - preventStealing: true - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: { - if (mouse.button === Qt.RightButton) - messageContextMenu.show(model.id, model.type, model.isEncrypted, row) - } - onPressAndHold: { - if (mouse.source === Qt.MouseEventNotSynthesized) - messageContextMenu.show(model.id, model.type, model.isEncrypted, row) + MouseArea { + anchors.fill: parent + propagateComposedEvents: true + preventStealing: true + hoverEnabled: true + + acceptedButtons: Qt.AllButtons + onClicked: { + if (mouse.button === Qt.RightButton) + messageContextMenu.show(model.id, model.type, model.isEncrypted, row) + } + onPressAndHold: { + messageContextMenu.show(model.id, model.type, model.isEncrypted, row, mapToItem(timelineRoot, mouse.x, mouse.y)) + } } Rectangle { color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent" @@ -45,7 +48,7 @@ MouseArea { // fancy reply, if this is a reply Reply { visible: model.replyTo - modelData: chat.model.getDump(model.replyTo) + modelData: chat.model.getDump(model.replyTo, model.id) userColor: timelineManager.userColor(modelData.userId, colors.window) } @@ -90,7 +93,6 @@ MouseArea { ToolTip.visible: hovered ToolTip.text: qsTr("React") emojiPicker: emojiPopup - room_id: model.roomId event_id: model.id } ImageButton { @@ -128,6 +130,7 @@ MouseArea { Label { Layout.alignment: Qt.AlignRight | Qt.AlignTop text: model.timestamp.toLocaleTimeString("HH:mm") + width: Math.max(implicitWidth, text.length*fontMetrics.maximumCharacterWidth) color: inactiveColors.text MouseArea{ diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 6af0372b..dd9c4029 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -11,6 +11,8 @@ import "./delegates" import "./emoji" Page { + id: timelineRoot + property var colors: currentActivePalette property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive @@ -25,34 +27,39 @@ Page { id: fontMetrics } - EmojiPicker { - id: emojiPopup - width: 7 * 52 + 20 - height: 6 * 52 - colors: palette - model: EmojiProxyModel { - category: EmojiCategory.People - sourceModel: EmojiModel {} - } - } + EmojiPicker { + id: emojiPopup + width: 7 * 52 + 20 + height: 6 * 52 + colors: palette + model: EmojiProxyModel { + category: EmojiCategory.People + sourceModel: EmojiModel {} + } + } Menu { id: messageContextMenu modal: true - function show(eventId_, eventType_, isEncrypted_, showAt) { + function show(eventId_, eventType_, isEncrypted_, showAt_, position) { eventId = eventId_ eventType = eventType_ isEncrypted = isEncrypted_ - popup(showAt) + + if (position) + popup(position, showAt_) + else + popup(showAt_) } property string eventId property int eventType property bool isEncrypted + MenuItem { text: qsTr("React") - onClicked: chat.model.reactAction(messageContextMenu.eventId) + onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId) } MenuItem { text: qsTr("Reply") @@ -87,8 +94,6 @@ Page { } } - id: timelineRoot - Rectangle { anchors.fill: parent color: colors.window @@ -113,7 +118,7 @@ Page { ListView { id: chat - visible: timelineManager.timeline != null + visible: !!timelineManager.timeline cacheBuffer: 400 @@ -181,7 +186,7 @@ Page { id: wrapper property Item section - anchors.horizontalCenter: parent.horizontalCenter + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined width: chat.delegateMaxWidth height: section ? section.height + timelinerow.height : timelinerow.height color: "transparent" @@ -205,14 +210,13 @@ Page { } } - Binding { - target: chat.model - property: "currentIndex" - when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height - value: index - delayed: true + Connections { + target: chat + function onMovementEnded() { + if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) + chat.model.currentIndex = index; + } } - } section { @@ -296,13 +300,13 @@ Page { } } - footer: BusyIndicator { - anchors.horizontalCenter: parent.horizontalCenter - running: chat.model && chat.model.paginationInProgress - height: 50 - width: 50 - z: 3 - } + footer: BusyIndicator { + anchors.horizontalCenter: parent.horizontalCenter + running: chat.model && chat.model.paginationInProgress + height: 50 + width: 50 + z: 3 + } } Rectangle { @@ -354,7 +358,7 @@ Page { anchors.rightMargin: 20 anchors.bottom: parent.bottom - modelData: chat.model ? chat.model.getDump(chat.model.reply) : {} + modelData: chat.model ? chat.model.getDump(chat.model.reply, chat.model.id) : {} userColor: timelineManager.userColor(modelData.userId, colors.window) } diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml index 62d9de60..3885ddae 100644 --- a/resources/qml/delegates/ImageMessage.qml +++ b/resources/qml/delegates/ImageMessage.qml @@ -9,8 +9,8 @@ Item { property double divisor: model.isReply ? 4 : 2 property bool tooHigh: tempHeight > timelineRoot.height / divisor - height: tooHigh ? timelineRoot.height / divisor : tempHeight - width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth + height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight) + width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth) Image { id: blurhash diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml index ed18b2e5..56b8040e 100644 --- a/resources/qml/delegates/MessageDelegate.qml +++ b/resources/qml/delegates/MessageDelegate.qml @@ -66,6 +66,12 @@ Item { text: qsTr("redacted") } } + DelegateChoice { + roleValue: MtxEvent.Redaction + Pill { + text: qsTr("redacted") + } + } DelegateChoice { roleValue: MtxEvent.Encryption Pill { @@ -108,6 +114,12 @@ Item { text: qsTr("%1 ended the call.").arg(model.data.userName) } } + DelegateChoice { + roleValue: MtxEvent.CallCandidates + NoticeMessage { + text: qsTr("Negotiating call...") + } + } DelegateChoice { // TODO: make a more complex formatter for the power levels. roleValue: MtxEvent.PowerLevels diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml index bab524eb..8d2fa8a8 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -9,7 +9,7 @@ Rectangle { id: bg radius: 10 color: colors.dark - height: content.height + 24 + height: Math.round(content.height + 24) width: parent ? parent.width : undefined Column { diff --git a/resources/qml/delegates/TextMessage.qml b/resources/qml/delegates/TextMessage.qml index b3c45c36..cc2d2da0 100644 --- a/resources/qml/delegates/TextMessage.qml +++ b/resources/qml/delegates/TextMessage.qml @@ -4,7 +4,7 @@ MatrixText { property string formatted: model.data.formattedBody text: "" + formatted.replace("
", "
")
 	width: parent ? parent.width : undefined
-	height: isReply ? Math.min(chat.height / 8, implicitHeight) : undefined
+	height: isReply ? Math.round(Math.min(timelineRoot.height / 8, implicitHeight)) : undefined
 	clip: true
 	font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize
 }
diff --git a/resources/qml/emoji/EmojiButton.qml b/resources/qml/emoji/EmojiButton.qml
index f8f75e3e..c5eee4e4 100644
--- a/resources/qml/emoji/EmojiButton.qml
+++ b/resources/qml/emoji/EmojiButton.qml
@@ -8,11 +8,10 @@ import "../"
 ImageButton {
     property var colors: currentActivePalette
     property var emojiPicker
-    property string room_id
     property string event_id
 
     image: ":/icons/icons/ui/smile.png"
     id: emojiButton
-    onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, room_id, event_id)
+    onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id)
 
 }
diff --git a/resources/qml/emoji/EmojiPicker.qml b/resources/qml/emoji/EmojiPicker.qml
index ac67af2a..f75221d5 100644
--- a/resources/qml/emoji/EmojiPicker.qml
+++ b/resources/qml/emoji/EmojiPicker.qml
@@ -10,17 +10,17 @@ import "../"
 
 Popup {
 
-    function show(showAt, room_id, event_id) {
-        console.debug("Showing emojiPicker for " + event_id + "in room " + room_id)
-        parent = showAt
-        x = Math.round((showAt.width - width) / 2)
-        y = showAt.height
-        emojiPopup.room_id = room_id
-        emojiPopup.event_id = event_id
-        open()
-    }
+	function show(showAt, event_id) {
+		console.debug("Showing emojiPicker for " + event_id)
+		if (showAt){
+			parent = showAt
+			x = Math.round((showAt.width - width) / 2)
+			y = showAt.height
+		}
+		emojiPopup.event_id = event_id
+		open()
+	}
 
-    property string room_id
     property string event_id
     property var colors
     property alias model: gridView.model
@@ -102,9 +102,9 @@ Popup {
                 }
                 // TODO: maybe add favorites at some point?
                 onClicked: {
-                    console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id + " in room " + emojiPopup.room_id)
+                    console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id)
                     emojiPopup.close()
-                    timelineManager.queueReactionMessage(emojiPopup.room_id, emojiPopup.event_id, model.unicode)
+                    timelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode)
                 }
             }
 
diff --git a/src/Cache.cpp b/src/Cache.cpp
index d435dc56..91cde9e7 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -33,11 +33,12 @@
 #include "Cache_p.h"
 #include "EventAccessors.h"
 #include "Logging.h"
+#include "Olm.h"
 #include "Utils.h"
 
 //! Should be changed when a breaking change occurs in the cache format.
 //! This will reset client's data.
-static const std::string CURRENT_CACHE_FORMAT_VERSION("2020.05.01");
+static const std::string CURRENT_CACHE_FORMAT_VERSION("2020.07.05");
 static const std::string SECRET("secret");
 
 static lmdb::val NEXT_BATCH_KEY("next_batch");
@@ -46,8 +47,9 @@ static lmdb::val CACHE_FORMAT_VERSION_KEY("cache_format_version");
 
 constexpr size_t MAX_RESTORED_MESSAGES = 30'000;
 
-constexpr auto DB_SIZE = 32ULL * 1024ULL * 1024ULL * 1024ULL; // 32 GB
-constexpr auto MAX_DBS = 8092UL;
+constexpr auto DB_SIZE    = 32ULL * 1024ULL * 1024ULL * 1024ULL; // 32 GB
+constexpr auto MAX_DBS    = 8092UL;
+constexpr auto BATCH_SIZE = 100;
 
 //! Cache databases and their format.
 //!
@@ -63,7 +65,6 @@ constexpr auto SYNC_STATE_DB("sync_state");
 //! Read receipts per room/event.
 constexpr auto READ_RECEIPTS_DB("read_receipts");
 constexpr auto NOTIFICATIONS_DB("sent_notifications");
-//! TODO: delete pending_receipts database on old cache versions
 
 //! Encryption related databases.
 
@@ -93,18 +94,31 @@ namespace {
 std::unique_ptr instance_ = nullptr;
 }
 
-int
-numeric_key_comparison(const MDB_val *a, const MDB_val *b)
+static bool
+isHiddenEvent(mtx::events::collections::TimelineEvents e, const std::string &room_id)
 {
-        auto lhs = std::stoull(std::string((char *)a->mv_data, a->mv_size));
-        auto rhs = std::stoull(std::string((char *)b->mv_data, b->mv_size));
+        using namespace mtx::events;
+        if (auto encryptedEvent = std::get_if>(&e)) {
+                MegolmSessionIndex index;
+                index.room_id    = room_id;
+                index.session_id = encryptedEvent->content.session_id;
+                index.sender_key = encryptedEvent->content.sender_key;
 
-        if (lhs < rhs)
-                return 1;
-        else if (lhs == rhs)
-                return 0;
+                auto result = olm::decryptEvent(index, *encryptedEvent);
+                if (!result.error)
+                        e = result.event.value();
+        }
 
-        return -1;
+        static constexpr std::initializer_list hiddenEvents = {
+          EventType::Reaction, EventType::CallCandidates, EventType::Unsupported};
+
+        return std::visit(
+          [](const auto &ev) {
+                  return std::any_of(hiddenEvents.begin(),
+                                     hiddenEvents.end(),
+                                     [ev](EventType type) { return type == ev.type; });
+          },
+          e);
 }
 
 Cache::Cache(const QString &userId, QObject *parent)
@@ -154,7 +168,10 @@ Cache::setup()
         }
 
         try {
-                env_.open(statePath.toStdString().c_str());
+                // NOTE(Nico): We may want to use (MDB_MAPASYNC | MDB_WRITEMAP) in the future, but
+                // it can really mess up our database, so we shouldn't. For now, hopefully
+                // NOMETASYNC is fast enough.
+                env_.open(statePath.toStdString().c_str(), MDB_NOMETASYNC);
         } catch (const lmdb::error &e) {
                 if (e.code() != MDB_VERSION_MISMATCH && e.code() != MDB_INVALID) {
                         throw std::runtime_error("LMDB initialization failed" +
@@ -697,11 +714,80 @@ Cache::runMigrations()
                            return false;
                    }
 
+                   nhlog::db()->info("Successfully deleted pending receipts database.");
+                   return true;
+           }},
+          {"2020.07.05",
+           [this]() {
+                   try {
+                           auto txn      = lmdb::txn::begin(env_, nullptr);
+                           auto room_ids = getRoomIds(txn);
+
+                           for (const auto &room_id : room_ids) {
+                                   try {
+                                           auto messagesDb = lmdb::dbi::open(
+                                             txn, std::string(room_id + "/messages").c_str());
+
+                                           // keep some old messages and batch token
+                                           {
+                                                   auto roomsCursor =
+                                                     lmdb::cursor::open(txn, messagesDb);
+                                                   lmdb::val ts, stored_message;
+                                                   bool start = true;
+                                                   mtx::responses::Timeline oldMessages;
+                                                   while (roomsCursor.get(ts,
+                                                                          stored_message,
+                                                                          start ? MDB_FIRST
+                                                                                : MDB_NEXT)) {
+                                                           start = false;
+
+                                                           auto j = json::parse(std::string_view(
+                                                             stored_message.data(),
+                                                             stored_message.size()));
+
+                                                           if (oldMessages.prev_batch.empty())
+                                                                   oldMessages.prev_batch =
+                                                                     j["token"].get();
+                                                           else if (j["token"] !=
+                                                                    oldMessages.prev_batch)
+                                                                   break;
+
+                                                           mtx::events::collections::TimelineEvent
+                                                             te;
+                                                           mtx::events::collections::from_json(
+                                                             j["event"], te);
+                                                           oldMessages.events.push_back(te.data);
+                                                   }
+                                                   // messages were stored in reverse order, so we
+                                                   // need to reverse them
+                                                   std::reverse(oldMessages.events.begin(),
+                                                                oldMessages.events.end());
+                                                   // save messages using the new method
+                                                   saveTimelineMessages(txn, room_id, oldMessages);
+                                           }
+
+                                           // delete old messages db
+                                           lmdb::dbi_drop(txn, messagesDb, true);
+                                   } catch (std::exception &e) {
+                                           nhlog::db()->error(
+                                             "While migrating messages from {}, ignoring error {}",
+                                             room_id,
+                                             e.what());
+                                   }
+                           }
+                           txn.commit();
+                   } catch (const lmdb::error &) {
+                           nhlog::db()->critical(
+                             "Failed to delete messages database in migration!");
+                           return false;
+                   }
+
                    nhlog::db()->info("Successfully deleted pending receipts database.");
                    return true;
            }},
         };
 
+        nhlog::db()->info("Running migrations, this may take a while!");
         for (const auto &[target_version, migration] : migrations) {
                 if (target_version > stored_version)
                         if (!migration()) {
@@ -709,6 +795,7 @@ Cache::runMigrations()
                                 return false;
                         }
         }
+        nhlog::db()->info("Migrations finished.");
 
         setCurrentFormat();
         return true;
@@ -771,8 +858,9 @@ Cache::readReceipts(const QString &event_id, const QString &room_id)
                 txn.commit();
 
                 if (res) {
-                        auto json_response = json::parse(std::string(value.data(), value.size()));
-                        auto values        = json_response.get>();
+                        auto json_response =
+                          json::parse(std::string_view(value.data(), value.size()));
+                        auto values = json_response.get>();
 
                         for (const auto &v : values)
                                 // timestamp, user_id
@@ -810,8 +898,8 @@ Cache::updateReadReceipt(lmdb::txn &txn, const std::string &room_id, const Recei
                         // If an entry for the event id already exists, we would
                         // merge the existing receipts with the new ones.
                         if (exists) {
-                                auto json_value =
-                                  json::parse(std::string(prev_value.data(), prev_value.size()));
+                                auto json_value = json::parse(
+                                  std::string_view(prev_value.data(), prev_value.size()));
 
                                 // Retrieve the saved receipts.
                                 saved_receipts = json_value.get>();
@@ -930,7 +1018,7 @@ Cache::saveState(const mtx::responses::Sync &res)
                         if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room.first), data)) {
                                 try {
                                         RoomInfo tmp =
-                                          json::parse(std::string(data.data(), data.size()));
+                                          json::parse(std::string_view(data.data(), data.size()));
                                         updatedInfo.tags = tmp.tags;
                                 } catch (const json::exception &e) {
                                         nhlog::db()->warn(
@@ -1122,7 +1210,7 @@ Cache::singleRoomInfo(const std::string &room_id)
         // Check if the room is joined.
         if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room_id), data)) {
                 try {
-                        RoomInfo tmp     = json::parse(std::string(data.data(), data.size()));
+                        RoomInfo tmp     = json::parse(std::string_view(data.data(), data.size()));
                         tmp.member_count = getMembersDb(txn, room_id).size(txn);
                         tmp.join_rule    = getRoomJoinRule(txn, statesdb);
                         tmp.guest_access = getRoomGuestAccess(txn, statesdb);
@@ -1157,7 +1245,8 @@ Cache::getRoomInfo(const std::vector &rooms)
                 // Check if the room is joined.
                 if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room), data)) {
                         try {
-                                RoomInfo tmp = json::parse(std::string(data.data(), data.size()));
+                                RoomInfo tmp =
+                                  json::parse(std::string_view(data.data(), data.size()));
                                 tmp.member_count = getMembersDb(txn, room).size(txn);
                                 tmp.join_rule    = getRoomJoinRule(txn, statesdb);
                                 tmp.guest_access = getRoomGuestAccess(txn, statesdb);
@@ -1173,7 +1262,7 @@ Cache::getRoomInfo(const std::vector &rooms)
                         if (lmdb::dbi_get(txn, invitesDb_, lmdb::val(room), data)) {
                                 try {
                                         RoomInfo tmp =
-                                          json::parse(std::string(data.data(), data.size()));
+                                          json::parse(std::string_view(data.data(), data.size()));
                                         tmp.member_count = getInviteMembersDb(txn, room).size(txn);
 
                                         room_info.emplace(QString::fromStdString(room),
@@ -1232,38 +1321,147 @@ Cache::getTimelineMentions()
         return notifs;
 }
 
-mtx::responses::Timeline
-Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id)
+std::string
+Cache::previousBatchToken(const std::string &room_id)
+{
+        auto txn     = lmdb::txn::begin(env_, nullptr);
+        auto orderDb = getEventOrderDb(txn, room_id);
+
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+        lmdb::val indexVal, val;
+        if (!cursor.get(indexVal, val, MDB_FIRST)) {
+                return "";
+        }
+
+        auto j = json::parse(std::string_view(val.data(), val.size()));
+
+        return j.value("prev_batch", "");
+}
+
+Cache::Messages
+Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t index, bool forward)
 {
         // TODO(nico): Limit the messages returned by this maybe?
-        auto db = getMessagesDb(txn, room_id);
+        auto orderDb  = getOrderToMessageDb(txn, room_id);
+        auto eventsDb = getEventsDb(txn, room_id);
 
-        mtx::responses::Timeline timeline;
-        std::string timestamp, msg;
+        Messages messages{};
 
-        auto cursor = lmdb::cursor::open(txn, db);
+        lmdb::val indexVal, event_id;
 
-        size_t index = 0;
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+        if (index == std::numeric_limits::max()) {
+                if (cursor.get(indexVal, event_id, forward ? MDB_FIRST : MDB_LAST)) {
+                        index = *indexVal.data();
+                } else {
+                        messages.end_of_cache = true;
+                        return messages;
+                }
+        } else {
+                if (cursor.get(indexVal, event_id, MDB_SET)) {
+                        index = *indexVal.data();
+                } else {
+                        messages.end_of_cache = true;
+                        return messages;
+                }
+        }
 
-        while (cursor.get(timestamp, msg, MDB_NEXT) && index < MAX_RESTORED_MESSAGES) {
-                auto obj = json::parse(msg);
+        int counter = 0;
 
-                if (obj.count("event") == 0 || obj.count("token") == 0)
+        bool ret;
+        while ((ret = cursor.get(indexVal,
+                                 event_id,
+                                 counter == 0 ? (forward ? MDB_FIRST : MDB_LAST)
+                                              : (forward ? MDB_NEXT : MDB_PREV))) &&
+               counter++ < BATCH_SIZE) {
+                lmdb::val event;
+                bool success = lmdb::dbi_get(txn, eventsDb, event_id, event);
+                if (!success)
                         continue;
 
-                mtx::events::collections::TimelineEvent event;
-                mtx::events::collections::from_json(obj.at("event"), event);
+                mtx::events::collections::TimelineEvent te;
+                try {
+                        mtx::events::collections::from_json(
+                          json::parse(std::string_view(event.data(), event.size())), te);
+                } catch (std::exception &e) {
+                        nhlog::db()->error("Failed to parse message from cache {}", e.what());
+                        continue;
+                }
 
-                index += 1;
-
-                timeline.events.push_back(event.data);
-                timeline.prev_batch = obj.at("token").get();
+                messages.timeline.events.push_back(std::move(te.data));
         }
         cursor.close();
 
-        std::reverse(timeline.events.begin(), timeline.events.end());
+        // std::reverse(timeline.events.begin(), timeline.events.end());
+        messages.next_index   = *indexVal.data();
+        messages.end_of_cache = !ret;
 
-        return timeline;
+        return messages;
+}
+
+std::optional
+Cache::getEvent(const std::string &room_id, const std::string &event_id)
+{
+        auto txn      = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+        auto eventsDb = getEventsDb(txn, room_id);
+
+        lmdb::val event{};
+        bool success = lmdb::dbi_get(txn, eventsDb, lmdb::val(event_id), event);
+        if (!success)
+                return {};
+
+        mtx::events::collections::TimelineEvent te;
+        try {
+                mtx::events::collections::from_json(
+                  json::parse(std::string_view(event.data(), event.size())), te);
+        } catch (std::exception &e) {
+                nhlog::db()->error("Failed to parse message from cache {}", e.what());
+                return std::nullopt;
+        }
+
+        return te;
+}
+void
+Cache::storeEvent(const std::string &room_id,
+                  const std::string &event_id,
+                  const mtx::events::collections::TimelineEvent &event)
+{
+        auto txn        = lmdb::txn::begin(env_);
+        auto eventsDb   = getEventsDb(txn, room_id);
+        auto event_json = mtx::accessors::serialize_event(event.data);
+        lmdb::dbi_put(txn, eventsDb, lmdb::val(event_id), lmdb::val(event_json.dump()));
+        txn.commit();
+}
+
+std::vector
+Cache::relatedEvents(const std::string &room_id, const std::string &event_id)
+{
+        auto txn         = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+        auto relationsDb = getRelationsDb(txn, room_id);
+
+        std::vector related_ids;
+
+        auto related_cursor  = lmdb::cursor::open(txn, relationsDb);
+        lmdb::val related_to = event_id, related_event;
+        bool first           = true;
+
+        try {
+                if (!related_cursor.get(related_to, related_event, MDB_SET))
+                        return {};
+
+                while (related_cursor.get(
+                  related_to, related_event, first ? MDB_FIRST_DUP : MDB_NEXT_DUP)) {
+                        first = false;
+                        if (event_id != std::string_view(related_to.data(), related_to.size()))
+                                break;
+
+                        related_ids.emplace_back(related_event.data(), related_event.size());
+                }
+        } catch (const lmdb::error &e) {
+                nhlog::db()->error("related events error: {}", e.what());
+        }
+
+        return related_ids;
 }
 
 QMap
@@ -1306,55 +1504,113 @@ Cache::roomInfo(bool withInvites)
 std::string
 Cache::getLastEventId(lmdb::txn &txn, const std::string &room_id)
 {
-        auto db = getMessagesDb(txn, room_id);
+        auto orderDb = getOrderToMessageDb(txn, room_id);
 
-        if (db.size(txn) == 0)
+        lmdb::val indexVal, val;
+
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+        if (!cursor.get(indexVal, val, MDB_LAST)) {
                 return {};
-
-        std::string timestamp, msg;
-
-        auto cursor = lmdb::cursor::open(txn, db);
-        while (cursor.get(timestamp, msg, MDB_NEXT)) {
-                auto obj = json::parse(msg);
-
-                if (obj.count("event") == 0)
-                        continue;
-
-                cursor.close();
-                return obj["event"]["event_id"];
         }
-        cursor.close();
 
-        return {};
+        return std::string(val.data(), val.size());
+}
+
+std::optional
+Cache::getTimelineRange(const std::string &room_id)
+{
+        auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+        lmdb::dbi orderDb{0};
+        try {
+                orderDb = getOrderToMessageDb(txn, room_id);
+        } catch (lmdb::runtime_error &e) {
+                nhlog::db()->error("Can't open db for room '{}', probably doesn't exist yet. ({})",
+                                   room_id,
+                                   e.what());
+                return {};
+        }
+
+        lmdb::val indexVal, val;
+
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+        if (!cursor.get(indexVal, val, MDB_LAST)) {
+                return {};
+        }
+
+        TimelineRange range{};
+        range.last = *indexVal.data();
+
+        if (!cursor.get(indexVal, val, MDB_FIRST)) {
+                return {};
+        }
+        range.first = *indexVal.data();
+
+        return range;
+}
+std::optional
+Cache::getTimelineIndex(const std::string &room_id, std::string_view event_id)
+{
+        auto txn     = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+        auto orderDb = getMessageToOrderDb(txn, room_id);
+
+        lmdb::val indexVal{event_id.data(), event_id.size()}, val;
+
+        bool success = lmdb::dbi_get(txn, orderDb, indexVal, val);
+        if (!success) {
+                return {};
+        }
+
+        return *val.data();
+}
+
+std::optional
+Cache::getTimelineEventId(const std::string &room_id, uint64_t index)
+{
+        auto txn     = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+        auto orderDb = getOrderToMessageDb(txn, room_id);
+
+        lmdb::val indexVal{&index, sizeof(index)}, val;
+
+        bool success = lmdb::dbi_get(txn, orderDb, indexVal, val);
+        if (!success) {
+                return {};
+        }
+
+        return std::string(val.data(), val.size());
 }
 
 DescInfo
 Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id)
 {
-        auto db = getMessagesDb(txn, room_id);
-
-        if (db.size(txn) == 0)
+        auto orderDb  = getOrderToMessageDb(txn, room_id);
+        auto eventsDb = getEventsDb(txn, room_id);
+        if (orderDb.size(txn) == 0)
                 return DescInfo{};
 
-        std::string timestamp, msg;
-
         const auto local_user = utils::localUser();
 
         DescInfo fallbackDesc{};
 
-        auto cursor = lmdb::cursor::open(txn, db);
-        while (cursor.get(timestamp, msg, MDB_NEXT)) {
-                auto obj = json::parse(msg);
+        lmdb::val indexVal, event_id;
 
-                if (obj.count("event") == 0)
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+        bool first  = true;
+        while (cursor.get(indexVal, event_id, first ? MDB_LAST : MDB_PREV)) {
+                first = false;
+
+                lmdb::val event;
+                bool success = lmdb::dbi_get(txn, eventsDb, event_id, event);
+                if (!success)
                         continue;
 
-                if (fallbackDesc.event_id.isEmpty() && obj["event"]["type"] == "m.room.member" &&
-                    obj["event"]["state_key"] == local_user.toStdString() &&
-                    obj["event"]["content"]["membership"] == "join") {
-                        uint64_t ts  = obj["event"]["origin_server_ts"];
+                auto obj = json::parse(std::string_view(event.data(), event.size()));
+
+                if (fallbackDesc.event_id.isEmpty() && obj["type"] == "m.room.member" &&
+                    obj["state_key"] == local_user.toStdString() &&
+                    obj["content"]["membership"] == "join") {
+                        uint64_t ts  = obj["origin_server_ts"];
                         auto time    = QDateTime::fromMSecsSinceEpoch(ts);
-                        fallbackDesc = DescInfo{QString::fromStdString(obj["event"]["event_id"]),
+                        fallbackDesc = DescInfo{QString::fromStdString(obj["event_id"]),
                                                 local_user,
                                                 tr("You joined this room."),
                                                 utils::descriptiveTime(time),
@@ -1362,20 +1618,17 @@ Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id)
                                                 time};
                 }
 
-                if (!(obj["event"]["type"] == "m.room.message" ||
-                      obj["event"]["type"] == "m.sticker" ||
-                      obj["event"]["type"] == "m.call.invite" ||
-                      obj["event"]["type"] == "m.call.answer" ||
-                      obj["event"]["type"] == "m.call.hangup" ||
-                      obj["event"]["type"] == "m.room.encrypted"))
+                if (!(obj["type"] == "m.room.message" || obj["type"] == "m.sticker" ||
+                      obj["type"] == "m.call.invite" || obj["type"] == "m.call.answer" ||
+                      obj["type"] == "m.call.hangup" || obj["type"] == "m.room.encrypted"))
                         continue;
 
-                mtx::events::collections::TimelineEvent event;
-                mtx::events::collections::from_json(obj.at("event"), event);
+                mtx::events::collections::TimelineEvent te;
+                mtx::events::collections::from_json(obj, te);
 
                 cursor.close();
                 return utils::getMessageDescription(
-                  event.data, local_user, QString::fromStdString(room_id));
+                  te.data, local_user, QString::fromStdString(room_id));
         }
         cursor.close();
 
@@ -1417,7 +1670,7 @@ Cache::getRoomAvatarUrl(lmdb::txn &txn,
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         if (!msg.content.url.empty())
                                 return QString::fromStdString(msg.content.url);
@@ -1467,7 +1720,8 @@ Cache::getRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &membersdb)
 
         if (res) {
                 try {
-                        StateEvent msg = json::parse(std::string(event.data(), event.size()));
+                        StateEvent msg =
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         if (!msg.content.name.empty())
                                 return QString::fromStdString(msg.content.name);
@@ -1482,7 +1736,7 @@ Cache::getRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &membersdb)
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         if (!msg.content.alias.empty())
                                 return QString::fromStdString(msg.content.alias);
@@ -1545,7 +1799,7 @@ Cache::getRoomJoinRule(lmdb::txn &txn, lmdb::dbi &statesdb)
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
                         return msg.content.join_rule;
                 } catch (const json::exception &e) {
                         nhlog::db()->warn("failed to parse m.room.join_rule event: {}", e.what());
@@ -1567,7 +1821,7 @@ Cache::getRoomGuestAccess(lmdb::txn &txn, lmdb::dbi &statesdb)
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
                         return msg.content.guest_access == AccessState::CanJoin;
                 } catch (const json::exception &e) {
                         nhlog::db()->warn("failed to parse m.room.guest_access event: {}",
@@ -1590,7 +1844,7 @@ Cache::getRoomTopic(lmdb::txn &txn, lmdb::dbi &statesdb)
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         if (!msg.content.topic.empty())
                                 return QString::fromStdString(msg.content.topic);
@@ -1615,7 +1869,7 @@ Cache::getRoomVersion(lmdb::txn &txn, lmdb::dbi &statesdb)
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         if (!msg.content.room_version.empty())
                                 return QString::fromStdString(msg.content.room_version);
@@ -1641,7 +1895,7 @@ Cache::getInviteRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &members
         if (res) {
                 try {
                         StrippedEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
                         return QString::fromStdString(msg.content.name);
                 } catch (const json::exception &e) {
                         nhlog::db()->warn("failed to parse m.room.name event: {}", e.what());
@@ -1683,7 +1937,7 @@ Cache::getInviteRoomAvatarUrl(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &me
         if (res) {
                 try {
                         StrippedEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
                         return QString::fromStdString(msg.content.url);
                 } catch (const json::exception &e) {
                         nhlog::db()->warn("failed to parse m.room.avatar event: {}", e.what());
@@ -1725,7 +1979,7 @@ Cache::getInviteRoomTopic(lmdb::txn &txn, lmdb::dbi &db)
         if (res) {
                 try {
                         StrippedEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
                         return QString::fromStdString(msg.content.topic);
                 } catch (const json::exception &e) {
                         nhlog::db()->warn("failed to parse m.room.topic event: {}", e.what());
@@ -1756,7 +2010,7 @@ Cache::getRoomAvatar(const std::string &room_id)
         std::string media_url;
 
         try {
-                RoomInfo info = json::parse(std::string(response.data(), response.size()));
+                RoomInfo info = json::parse(std::string_view(response.data(), response.size()));
                 media_url     = std::move(info.avatar_url);
 
                 if (media_url.empty()) {
@@ -1952,31 +2206,439 @@ Cache::isRoomMember(const std::string &user_id, const std::string &room_id)
         return res;
 }
 
+void
+Cache::savePendingMessage(const std::string &room_id,
+                          const mtx::events::collections::TimelineEvent &message)
+{
+        auto txn = lmdb::txn::begin(env_);
+
+        mtx::responses::Timeline timeline;
+        timeline.events.push_back(message.data);
+        saveTimelineMessages(txn, room_id, timeline);
+
+        auto pending = getPendingMessagesDb(txn, room_id);
+
+        int64_t now = QDateTime::currentMSecsSinceEpoch();
+        lmdb::dbi_put(txn,
+                      pending,
+                      lmdb::val(&now, sizeof(now)),
+                      lmdb::val(mtx::accessors::event_id(message.data)));
+
+        txn.commit();
+}
+
+std::optional
+Cache::firstPendingMessage(const std::string &room_id)
+{
+        auto txn     = lmdb::txn::begin(env_);
+        auto pending = getPendingMessagesDb(txn, room_id);
+
+        {
+                auto pendingCursor = lmdb::cursor::open(txn, pending);
+                lmdb::val tsIgnored, pendingTxn;
+                while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) {
+                        auto eventsDb = getEventsDb(txn, room_id);
+                        lmdb::val event;
+                        if (!lmdb::dbi_get(txn, eventsDb, pendingTxn, event)) {
+                                lmdb::dbi_del(txn, pending, tsIgnored, pendingTxn);
+                                continue;
+                        }
+
+                        try {
+                                mtx::events::collections::TimelineEvent te;
+                                mtx::events::collections::from_json(
+                                  json::parse(std::string_view(event.data(), event.size())), te);
+
+                                pendingCursor.close();
+                                txn.commit();
+                                return te;
+                        } catch (std::exception &e) {
+                                nhlog::db()->error("Failed to parse message from cache {}",
+                                                   e.what());
+                                lmdb::dbi_del(txn, pending, tsIgnored, pendingTxn);
+                                continue;
+                        }
+                }
+        }
+
+        txn.commit();
+
+        return std::nullopt;
+}
+
+void
+Cache::removePendingStatus(const std::string &room_id, const std::string &txn_id)
+{
+        auto txn     = lmdb::txn::begin(env_);
+        auto pending = getPendingMessagesDb(txn, room_id);
+
+        {
+                auto pendingCursor = lmdb::cursor::open(txn, pending);
+                lmdb::val tsIgnored, pendingTxn;
+                while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) {
+                        if (std::string_view(pendingTxn.data(), pendingTxn.size()) == txn_id)
+                                lmdb::cursor_del(pendingCursor);
+                }
+        }
+
+        txn.commit();
+}
+
 void
 Cache::saveTimelineMessages(lmdb::txn &txn,
                             const std::string &room_id,
                             const mtx::responses::Timeline &res)
 {
-        auto db = getMessagesDb(txn, room_id);
+        if (res.events.empty())
+                return;
+
+        auto eventsDb    = getEventsDb(txn, room_id);
+        auto relationsDb = getRelationsDb(txn, room_id);
+
+        auto orderDb     = getEventOrderDb(txn, room_id);
+        auto evToOrderDb = getEventToOrderDb(txn, room_id);
+        auto msg2orderDb = getMessageToOrderDb(txn, room_id);
+        auto order2msgDb = getOrderToMessageDb(txn, room_id);
+        auto pending     = getPendingMessagesDb(txn, room_id);
+
+        if (res.limited) {
+                lmdb::dbi_drop(txn, orderDb, false);
+                lmdb::dbi_drop(txn, evToOrderDb, false);
+                lmdb::dbi_drop(txn, msg2orderDb, false);
+                lmdb::dbi_drop(txn, order2msgDb, false);
+                lmdb::dbi_drop(txn, pending, true);
+        }
 
         using namespace mtx::events;
         using namespace mtx::events::state;
 
+        lmdb::val indexVal, val;
+        uint64_t index = std::numeric_limits::max() / 2;
+        auto cursor    = lmdb::cursor::open(txn, orderDb);
+        if (cursor.get(indexVal, val, MDB_LAST)) {
+                index = *indexVal.data();
+        }
+
+        uint64_t msgIndex = std::numeric_limits::max() / 2;
+        auto msgCursor    = lmdb::cursor::open(txn, order2msgDb);
+        if (msgCursor.get(indexVal, val, MDB_LAST)) {
+                msgIndex = *indexVal.data();
+        }
+
+        bool first = true;
         for (const auto &e : res.events) {
-                if (std::holds_alternative>(e))
+                auto event  = mtx::accessors::serialize_event(e);
+                auto txn_id = mtx::accessors::transaction_id(e);
+
+                std::string event_id_val = event.value("event_id", "");
+                if (event_id_val.empty()) {
+                        nhlog::db()->error("Event without id!");
+                        continue;
+                }
+
+                lmdb::val event_id = event_id_val;
+
+                json orderEntry        = json::object();
+                orderEntry["event_id"] = event_id_val;
+                if (first && !res.prev_batch.empty())
+                        orderEntry["prev_batch"] = res.prev_batch;
+
+                lmdb::val txn_order;
+                if (!txn_id.empty() &&
+                    lmdb::dbi_get(txn, evToOrderDb, lmdb::val(txn_id), txn_order)) {
+                        lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump()));
+                        lmdb::dbi_del(txn, eventsDb, lmdb::val(txn_id));
+
+                        lmdb::val msg_txn_order;
+                        if (lmdb::dbi_get(txn, msg2orderDb, lmdb::val(txn_id), msg_txn_order)) {
+                                lmdb::dbi_put(txn, order2msgDb, msg_txn_order, event_id);
+                                lmdb::dbi_put(txn, msg2orderDb, event_id, msg_txn_order);
+                                lmdb::dbi_del(txn, msg2orderDb, lmdb::val(txn_id));
+                        }
+
+                        lmdb::dbi_put(txn, orderDb, txn_order, lmdb::val(orderEntry.dump()));
+                        lmdb::dbi_put(txn, evToOrderDb, event_id, txn_order);
+                        lmdb::dbi_del(txn, evToOrderDb, lmdb::val(txn_id));
+
+                        if (event.contains("content") &&
+                            event["content"].contains("m.relates_to")) {
+                                auto temp              = event["content"]["m.relates_to"];
+                                std::string relates_to = temp.contains("m.in_reply_to")
+                                                           ? temp["m.in_reply_to"]["event_id"]
+                                                           : temp["event_id"];
+
+                                if (!relates_to.empty()) {
+                                        lmdb::dbi_del(txn,
+                                                      relationsDb,
+                                                      lmdb::val(relates_to),
+                                                      lmdb::val(txn_id));
+                                        lmdb::dbi_put(
+                                          txn, relationsDb, lmdb::val(relates_to), event_id);
+                                }
+                        }
+
+                        auto pendingCursor = lmdb::cursor::open(txn, pending);
+                        lmdb::val tsIgnored, pendingTxn;
+                        while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) {
+                                if (std::string_view(pendingTxn.data(), pendingTxn.size()) ==
+                                    txn_id)
+                                        lmdb::cursor_del(pendingCursor);
+                        }
+                } else if (auto redaction =
+                             std::get_if>(
+                               &e)) {
+                        if (redaction->redacts.empty())
+                                continue;
+
+                        lmdb::val oldEvent;
+                        bool success =
+                          lmdb::dbi_get(txn, eventsDb, lmdb::val(redaction->redacts), oldEvent);
+                        if (!success)
+                                continue;
+
+                        mtx::events::collections::TimelineEvent te;
+                        try {
+                                mtx::events::collections::from_json(
+                                  json::parse(std::string_view(oldEvent.data(), oldEvent.size())),
+                                  te);
+                                // overwrite the content and add redation data
+                                std::visit(
+                                  [redaction](auto &ev) {
+                                          ev.unsigned_data.redacted_because = *redaction;
+                                          ev.unsigned_data.redacted_by      = redaction->event_id;
+                                  },
+                                  te.data);
+                                event = mtx::accessors::serialize_event(te.data);
+                                event["content"].clear();
+
+                        } catch (std::exception &e) {
+                                nhlog::db()->error("Failed to parse message from cache {}",
+                                                   e.what());
+                                continue;
+                        }
+
+                        lmdb::dbi_put(
+                          txn, eventsDb, lmdb::val(redaction->redacts), lmdb::val(event.dump()));
+                        lmdb::dbi_put(txn,
+                                      eventsDb,
+                                      lmdb::val(redaction->event_id),
+                                      lmdb::val(json(*redaction).dump()));
+                } else {
+                        lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump()));
+
+                        ++index;
+
+                        first = false;
+
+                        nhlog::db()->debug("saving '{}'", orderEntry.dump());
+
+                        lmdb::cursor_put(cursor.handle(),
+                                         lmdb::val(&index, sizeof(index)),
+                                         lmdb::val(orderEntry.dump()),
+                                         MDB_APPEND);
+                        lmdb::dbi_put(txn, evToOrderDb, event_id, lmdb::val(&index, sizeof(index)));
+
+                        // TODO(Nico): Allow blacklisting more event types in UI
+                        if (!isHiddenEvent(e, room_id)) {
+                                ++msgIndex;
+                                lmdb::cursor_put(msgCursor.handle(),
+                                                 lmdb::val(&msgIndex, sizeof(msgIndex)),
+                                                 event_id,
+                                                 MDB_APPEND);
+
+                                lmdb::dbi_put(txn,
+                                              msg2orderDb,
+                                              event_id,
+                                              lmdb::val(&msgIndex, sizeof(msgIndex)));
+                        }
+
+                        if (event.contains("content") &&
+                            event["content"].contains("m.relates_to")) {
+                                auto temp              = event["content"]["m.relates_to"];
+                                std::string relates_to = temp.contains("m.in_reply_to")
+                                                           ? temp["m.in_reply_to"]["event_id"]
+                                                           : temp["event_id"];
+
+                                if (!relates_to.empty())
+                                        lmdb::dbi_put(
+                                          txn, relationsDb, lmdb::val(relates_to), event_id);
+                        }
+                }
+        }
+}
+
+uint64_t
+Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res)
+{
+        auto txn         = lmdb::txn::begin(env_);
+        auto eventsDb    = getEventsDb(txn, room_id);
+        auto relationsDb = getRelationsDb(txn, room_id);
+
+        auto orderDb     = getEventOrderDb(txn, room_id);
+        auto evToOrderDb = getEventToOrderDb(txn, room_id);
+        auto msg2orderDb = getMessageToOrderDb(txn, room_id);
+        auto order2msgDb = getOrderToMessageDb(txn, room_id);
+
+        lmdb::val indexVal, val;
+        uint64_t index = std::numeric_limits::max() / 2;
+        {
+                auto cursor = lmdb::cursor::open(txn, orderDb);
+                if (cursor.get(indexVal, val, MDB_FIRST)) {
+                        index = *indexVal.data();
+                }
+        }
+
+        uint64_t msgIndex = std::numeric_limits::max() / 2;
+        {
+                auto msgCursor = lmdb::cursor::open(txn, order2msgDb);
+                if (msgCursor.get(indexVal, val, MDB_FIRST)) {
+                        msgIndex = *indexVal.data();
+                }
+        }
+
+        if (res.chunk.empty())
+                return index;
+
+        std::string event_id_val;
+        for (const auto &e : res.chunk) {
+                if (std::holds_alternative<
+                      mtx::events::RedactionEvent>(e))
                         continue;
 
-                json obj = json::object();
+                auto event         = mtx::accessors::serialize_event(e);
+                event_id_val       = event["event_id"].get();
+                lmdb::val event_id = event_id_val;
+                lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump()));
 
-                obj["event"] = mtx::accessors::serialize_event(e);
-                obj["token"] = res.prev_batch;
+                --index;
+
+                json orderEntry        = json::object();
+                orderEntry["event_id"] = event_id_val;
+
+                nhlog::db()->debug("saving '{}'", orderEntry.dump());
 
                 lmdb::dbi_put(
-                  txn,
-                  db,
-                  lmdb::val(std::to_string(obj["event"]["origin_server_ts"].get())),
-                  lmdb::val(obj.dump()));
+                  txn, orderDb, lmdb::val(&index, sizeof(index)), lmdb::val(orderEntry.dump()));
+                lmdb::dbi_put(txn, evToOrderDb, event_id, lmdb::val(&index, sizeof(index)));
+
+                // TODO(Nico): Allow blacklisting more event types in UI
+                if (!isHiddenEvent(e, room_id)) {
+                        --msgIndex;
+                        lmdb::dbi_put(
+                          txn, order2msgDb, lmdb::val(&msgIndex, sizeof(msgIndex)), event_id);
+
+                        lmdb::dbi_put(
+                          txn, msg2orderDb, event_id, lmdb::val(&msgIndex, sizeof(msgIndex)));
+                }
+
+                if (event.contains("content") && event["content"].contains("m.relates_to")) {
+                        auto temp              = event["content"]["m.relates_to"];
+                        std::string relates_to = temp.contains("m.in_reply_to")
+                                                   ? temp["m.in_reply_to"]["event_id"]
+                                                   : temp["event_id"];
+
+                        if (!relates_to.empty())
+                                lmdb::dbi_put(txn, relationsDb, lmdb::val(relates_to), event_id);
+                }
         }
+
+        json orderEntry          = json::object();
+        orderEntry["event_id"]   = event_id_val;
+        orderEntry["prev_batch"] = res.end;
+        lmdb::dbi_put(txn, orderDb, lmdb::val(&index, sizeof(index)), lmdb::val(orderEntry.dump()));
+        nhlog::db()->debug("saving '{}'", orderEntry.dump());
+
+        txn.commit();
+
+        return msgIndex;
+}
+
+void
+Cache::clearTimeline(const std::string &room_id)
+{
+        auto txn         = lmdb::txn::begin(env_);
+        auto eventsDb    = getEventsDb(txn, room_id);
+        auto relationsDb = getRelationsDb(txn, room_id);
+
+        auto orderDb     = getEventOrderDb(txn, room_id);
+        auto evToOrderDb = getEventToOrderDb(txn, room_id);
+        auto msg2orderDb = getMessageToOrderDb(txn, room_id);
+        auto order2msgDb = getOrderToMessageDb(txn, room_id);
+
+        lmdb::val indexVal, val;
+        auto cursor = lmdb::cursor::open(txn, orderDb);
+
+        bool start                   = true;
+        bool passed_pagination_token = false;
+        while (cursor.get(indexVal, val, start ? MDB_LAST : MDB_PREV)) {
+                start = false;
+                json obj;
+
+                try {
+                        obj = json::parse(std::string_view(val.data(), val.size()));
+                } catch (std::exception &) {
+                        // workaround bug in the initial db format, where we sometimes didn't store
+                        // json...
+                        obj = {{"event_id", std::string(val.data(), val.size())}};
+                }
+
+                if (passed_pagination_token) {
+                        if (obj.count("event_id") != 0) {
+                                lmdb::val event_id = obj["event_id"].get();
+                                lmdb::dbi_del(txn, evToOrderDb, event_id);
+                                lmdb::dbi_del(txn, eventsDb, event_id);
+
+                                lmdb::dbi_del(txn, relationsDb, event_id);
+
+                                lmdb::val order{};
+                                bool exists = lmdb::dbi_get(txn, msg2orderDb, event_id, order);
+                                if (exists) {
+                                        lmdb::dbi_del(txn, order2msgDb, order);
+                                        lmdb::dbi_del(txn, msg2orderDb, event_id);
+                                }
+                        }
+                        lmdb::cursor_del(cursor);
+                } else {
+                        if (obj.count("prev_batch") != 0)
+                                passed_pagination_token = true;
+                }
+        }
+
+        auto msgCursor = lmdb::cursor::open(txn, order2msgDb);
+        start          = true;
+        while (msgCursor.get(indexVal, val, start ? MDB_LAST : MDB_PREV)) {
+                start = false;
+
+                lmdb::val eventId;
+                bool innerStart = true;
+                bool found      = false;
+                while (cursor.get(indexVal, eventId, innerStart ? MDB_LAST : MDB_PREV)) {
+                        innerStart = false;
+
+                        json obj;
+                        try {
+                                obj = json::parse(std::string_view(eventId.data(), eventId.size()));
+                        } catch (std::exception &) {
+                                obj = {{"event_id", std::string(eventId.data(), eventId.size())}};
+                        }
+
+                        if (obj["event_id"] == std::string(val.data(), val.size())) {
+                                found = true;
+                                break;
+                        }
+                }
+
+                if (!found)
+                        break;
+        }
+
+        do {
+                lmdb::cursor_del(msgCursor);
+        } while (msgCursor.get(indexVal, val, MDB_PREV));
+
+        cursor.close();
+        msgCursor.close();
+        txn.commit();
 }
 
 mtx::responses::Notifications
@@ -2111,34 +2773,60 @@ Cache::getRoomIds(lmdb::txn &txn)
 void
 Cache::deleteOldMessages()
 {
+        lmdb::val indexVal, val;
+
         auto txn      = lmdb::txn::begin(env_);
         auto room_ids = getRoomIds(txn);
 
-        for (const auto &id : room_ids) {
-                auto msg_db = getMessagesDb(txn, id);
+        for (const auto &room_id : room_ids) {
+                auto orderDb     = getEventOrderDb(txn, room_id);
+                auto evToOrderDb = getEventToOrderDb(txn, room_id);
+                auto o2m         = getOrderToMessageDb(txn, room_id);
+                auto m2o         = getMessageToOrderDb(txn, room_id);
+                auto eventsDb    = getEventsDb(txn, room_id);
+                auto relationsDb = getRelationsDb(txn, room_id);
+                auto cursor      = lmdb::cursor::open(txn, orderDb);
 
-                std::string ts, event;
-                uint64_t idx = 0;
-
-                const auto db_size = msg_db.size(txn);
-                if (db_size <= 3 * MAX_RESTORED_MESSAGES)
+                uint64_t first, last;
+                if (cursor.get(indexVal, val, MDB_LAST)) {
+                        last = *indexVal.data();
+                } else {
+                        continue;
+                }
+                if (cursor.get(indexVal, val, MDB_FIRST)) {
+                        first = *indexVal.data();
+                } else {
                         continue;
-
-                nhlog::db()->info("[{}] message count: {}", id, db_size);
-
-                auto cursor = lmdb::cursor::open(txn, msg_db);
-                while (cursor.get(ts, event, MDB_NEXT)) {
-                        idx += 1;
-
-                        if (idx > MAX_RESTORED_MESSAGES)
-                                lmdb::cursor_del(cursor);
                 }
 
+                size_t message_count = static_cast(last - first);
+                if (message_count < MAX_RESTORED_MESSAGES)
+                        continue;
+
+                bool start = true;
+                while (cursor.get(indexVal, val, start ? MDB_FIRST : MDB_NEXT) &&
+                       message_count-- > MAX_RESTORED_MESSAGES) {
+                        start    = false;
+                        auto obj = json::parse(std::string_view(val.data(), val.size()));
+
+                        if (obj.count("event_id") != 0) {
+                                lmdb::val event_id = obj["event_id"].get();
+                                lmdb::dbi_del(txn, evToOrderDb, event_id);
+                                lmdb::dbi_del(txn, eventsDb, event_id);
+
+                                lmdb::dbi_del(txn, relationsDb, event_id);
+
+                                lmdb::val order{};
+                                bool exists = lmdb::dbi_get(txn, m2o, event_id, order);
+                                if (exists) {
+                                        lmdb::dbi_del(txn, o2m, order);
+                                        lmdb::dbi_del(txn, m2o, event_id);
+                                }
+                        }
+                        lmdb::cursor_del(cursor);
+                }
                 cursor.close();
-
-                nhlog::db()->info("[{}] updated message count: {}", id, msg_db.size(txn));
         }
-
         txn.commit();
 }
 
@@ -2172,7 +2860,7 @@ Cache::hasEnoughPowerLevel(const std::vector &eventTypes
         if (res) {
                 try {
                         StateEvent msg =
-                          json::parse(std::string(event.data(), event.size()));
+                          json::parse(std::string_view(event.data(), event.size()));
 
                         user_level = msg.content.user_level(user_id);
 
@@ -2277,6 +2965,9 @@ Cache::removeAvatarUrl(const QString &room_id, const QString &user_id)
 mtx::presence::PresenceState
 Cache::presenceState(const std::string &user_id)
 {
+        if (user_id.empty())
+                return {};
+
         lmdb::val presenceVal;
 
         auto txn = lmdb::txn::begin(env_);
@@ -2287,7 +2978,7 @@ Cache::presenceState(const std::string &user_id)
 
         if (res) {
                 mtx::events::presence::Presence presence =
-                  json::parse(std::string(presenceVal.data(), presenceVal.size()));
+                  json::parse(std::string_view(presenceVal.data(), presenceVal.size()));
                 state = presence.presence;
         }
 
@@ -2299,6 +2990,9 @@ Cache::presenceState(const std::string &user_id)
 std::string
 Cache::statusMessage(const std::string &user_id)
 {
+        if (user_id.empty())
+                return {};
+
         lmdb::val presenceVal;
 
         auto txn = lmdb::txn::begin(env_);
@@ -2309,7 +3003,7 @@ Cache::statusMessage(const std::string &user_id)
 
         if (res) {
                 mtx::events::presence::Presence presence =
-                  json::parse(std::string(presenceVal.data(), presenceVal.size()));
+                  json::parse(std::string_view(presenceVal.data(), presenceVal.size()));
                 status_msg = presence.status_msg;
         }
 
diff --git a/src/Cache_p.h b/src/Cache_p.h
index 892b66a5..d3ec6ee0 100644
--- a/src/Cache_p.h
+++ b/src/Cache_p.h
@@ -18,6 +18,7 @@
 
 #pragma once
 
+#include 
 #include 
 
 #include 
@@ -38,9 +39,6 @@
 #include "CacheCryptoStructs.h"
 #include "CacheStructs.h"
 
-int
-numeric_key_comparison(const MDB_val *a, const MDB_val *b);
-
 class Cache : public QObject
 {
         Q_OBJECT
@@ -172,6 +170,47 @@ public:
         //! Add all notifications containing a user mention to the db.
         void saveTimelineMentions(const mtx::responses::Notifications &res);
 
+        //! retrieve events in timeline and related functions
+        struct Messages
+        {
+                mtx::responses::Timeline timeline;
+                uint64_t next_index;
+                bool end_of_cache = false;
+        };
+        Messages getTimelineMessages(lmdb::txn &txn,
+                                     const std::string &room_id,
+                                     uint64_t index = std::numeric_limits::max(),
+                                     bool forward   = false);
+
+        std::optional getEvent(
+          const std::string &room_id,
+          const std::string &event_id);
+        void storeEvent(const std::string &room_id,
+                        const std::string &event_id,
+                        const mtx::events::collections::TimelineEvent &event);
+        std::vector relatedEvents(const std::string &room_id,
+                                               const std::string &event_id);
+
+        struct TimelineRange
+        {
+                uint64_t first, last;
+        };
+        std::optional getTimelineRange(const std::string &room_id);
+        std::optional getTimelineIndex(const std::string &room_id,
+                                                 std::string_view event_id);
+        std::optional getTimelineEventId(const std::string &room_id, uint64_t index);
+
+        std::string previousBatchToken(const std::string &room_id);
+        uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res);
+        void savePendingMessage(const std::string &room_id,
+                                const mtx::events::collections::TimelineEvent &message);
+        std::optional firstPendingMessage(
+          const std::string &room_id);
+        void removePendingStatus(const std::string &room_id, const std::string &txn_id);
+
+        //! clear timeline keeping only the latest batch
+        void clearTimeline(const std::string &room_id);
+
         //! Remove old unused data.
         void deleteOldMessages();
         void deleteOldData() noexcept;
@@ -250,8 +289,6 @@ private:
                                   const std::string &room_id,
                                   const mtx::responses::Timeline &res);
 
-        mtx::responses::Timeline getTimelineMessages(lmdb::txn &txn, const std::string &room_id);
-
         //! Remove a room from the cache.
         // void removeLeftRoom(lmdb::txn &txn, const std::string &room_id);
         template
@@ -402,13 +439,46 @@ private:
                 return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE);
         }
 
-        lmdb::dbi getMessagesDb(lmdb::txn &txn, const std::string &room_id)
+        lmdb::dbi getEventsDb(lmdb::txn &txn, const std::string &room_id)
         {
-                auto db =
-                  lmdb::dbi::open(txn, std::string(room_id + "/messages").c_str(), MDB_CREATE);
-                lmdb::dbi_set_compare(txn, db, numeric_key_comparison);
+                return lmdb::dbi::open(txn, std::string(room_id + "/events").c_str(), MDB_CREATE);
+        }
 
-                return db;
+        lmdb::dbi getEventOrderDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/event_order").c_str(), MDB_CREATE | MDB_INTEGERKEY);
+        }
+
+        // inverse of EventOrderDb
+        lmdb::dbi getEventToOrderDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/event2order").c_str(), MDB_CREATE);
+        }
+
+        lmdb::dbi getMessageToOrderDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/msg2order").c_str(), MDB_CREATE);
+        }
+
+        lmdb::dbi getOrderToMessageDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/order2msg").c_str(), MDB_CREATE | MDB_INTEGERKEY);
+        }
+
+        lmdb::dbi getPendingMessagesDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/pending").c_str(), MDB_CREATE | MDB_INTEGERKEY);
+        }
+
+        lmdb::dbi getRelationsDb(lmdb::txn &txn, const std::string &room_id)
+        {
+                return lmdb::dbi::open(
+                  txn, std::string(room_id + "/related").c_str(), MDB_CREATE | MDB_DUPSORT);
         }
 
         lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id)
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 84a5e4d3..e55b3eca 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -165,6 +165,11 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                 trySync();
         });
 
+        connect(text_input_,
+                &TextInputWidget::clearRoomTimeline,
+                view_manager_,
+                &TimelineViewManager::clearCurrentRoomTimeline);
+
         connect(
           new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
                   if (isVisible())
@@ -254,7 +259,6 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
           room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
 
         connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) {
-                view_manager_->addRoom(room_id);
                 joinRoom(room_id);
                 room_list_->removeRoom(room_id, currentRoom() == room_id);
         });
@@ -323,17 +327,15 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                       .toStdString();
                   member.membership = mtx::events::state::Membership::Join;
 
-                  http::client()
-                    ->send_state_event(
-                      currentRoom().toStdString(),
-                      http::client()->user_id().to_string(),
-                      member,
-                      [](mtx::responses::EventId, mtx::http::RequestErr err) {
-                              if (err)
-                                      nhlog::net()->error("Failed to set room displayname: {}",
-                                                          err->matrix_error.error);
-                      });
+                  http::client()->send_state_event(
+                    currentRoom().toStdString(),
+                    http::client()->user_id().to_string(),
+                    member,
+                    [](mtx::responses::EventId, mtx::http::RequestErr err) {
+                            if (err)
+                                    nhlog::net()->error("Failed to set room displayname: {}",
+                                                        err->matrix_error.error);
+                    });
           });
 
         connect(
@@ -584,12 +586,8 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
                                   emit notificationsRetrieved(std::move(res));
                           });
         });
-        connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync, Qt::QueuedConnection);
-        connect(this,
-                &ChatPage::syncTags,
-                communitiesList_,
-                &CommunitiesList::syncTags,
-                Qt::QueuedConnection);
+        connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync);
+        connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags);
         connect(
           this, &ChatPage::syncTopBar, this, [this](const std::map &updates) {
                   if (updates.find(currentRoom()) != updates.end())
@@ -614,6 +612,12 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
           [this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); },
           Qt::QueuedConnection);
 
+        connect(this,
+                &ChatPage::newSyncResponse,
+                this,
+                &ChatPage::handleSyncResponse,
+                Qt::QueuedConnection);
+
         connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
 
         connectCallMessage();
@@ -841,43 +845,39 @@ ChatPage::loadStateFromCache()
 
         nhlog::db()->info("restoring state from cache");
 
+        try {
+                cache::restoreSessions();
+                olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
+
+                cache::populateMembers();
+
+                emit initializeEmptyViews(cache::roomMessages());
+                emit initializeRoomList(cache::roomInfo());
+                emit initializeMentions(cache::getTimelineMentions());
+                emit syncTags(cache::roomInfo().toStdMap());
+
+                cache::calculateRoomReadStatus();
+
+        } catch (const mtx::crypto::olm_exception &e) {
+                nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
+                emit dropToLoginPageCb(tr("Failed to restore OLM account. Please login again."));
+                return;
+        } catch (const lmdb::error &e) {
+                nhlog::db()->critical("failed to restore cache: {}", e.what());
+                emit dropToLoginPageCb(tr("Failed to restore save data. Please login again."));
+                return;
+        } catch (const json::exception &e) {
+                nhlog::db()->critical("failed to parse cache data: {}", e.what());
+                return;
+        }
+
+        nhlog::crypto()->info("ed25519   : {}", olm::client()->identity_keys().ed25519);
+        nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
+
         getProfileInfo();
 
-        QtConcurrent::run([this]() {
-                try {
-                        cache::restoreSessions();
-                        olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
-
-                        cache::populateMembers();
-
-                        emit initializeEmptyViews(cache::roomMessages());
-                        emit initializeRoomList(cache::roomInfo());
-                        emit initializeMentions(cache::getTimelineMentions());
-                        emit syncTags(cache::roomInfo().toStdMap());
-
-                        cache::calculateRoomReadStatus();
-
-                } catch (const mtx::crypto::olm_exception &e) {
-                        nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
-                        emit dropToLoginPageCb(
-                          tr("Failed to restore OLM account. Please login again."));
-                        return;
-                } catch (const lmdb::error &e) {
-                        nhlog::db()->critical("failed to restore cache: {}", e.what());
-                        emit dropToLoginPageCb(
-                          tr("Failed to restore save data. Please login again."));
-                        return;
-                } catch (const json::exception &e) {
-                        nhlog::db()->critical("failed to parse cache data: {}", e.what());
-                        return;
-                }
-
-                nhlog::crypto()->info("ed25519   : {}", olm::client()->identity_keys().ed25519);
-                nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
-
-                // Start receiving events.
-                emit trySyncCb();
-        });
+        // Start receiving events.
+        emit trySyncCb();
 }
 
 void
@@ -1055,6 +1055,45 @@ ChatPage::startInitialSync()
             &ChatPage::initialSyncHandler, this, std::placeholders::_1, std::placeholders::_2));
 }
 
+void
+ChatPage::handleSyncResponse(mtx::responses::Sync res)
+{
+        nhlog::net()->debug("sync completed: {}", res.next_batch);
+
+        // Ensure that we have enough one-time keys available.
+        ensureOneTimeKeyCount(res.device_one_time_keys_count);
+
+        // TODO: fine grained error handling
+        try {
+                cache::saveState(res);
+                olm::handle_to_device_messages(res.to_device.events);
+
+                auto updates = cache::roomUpdates(res);
+
+                emit syncTopBar(updates);
+                emit syncRoomlist(updates);
+
+                emit syncUI(res.rooms);
+
+                emit syncTags(cache::roomTagUpdates(res));
+
+                // if we process a lot of syncs (1 every 200ms), this means we clean the
+                // db every 100s
+                static int syncCounter = 0;
+                if (syncCounter++ >= 500) {
+                        cache::deleteOldData();
+                        syncCounter = 0;
+                }
+        } catch (const lmdb::map_full_error &e) {
+                nhlog::db()->error("lmdb is full: {}", e.what());
+                cache::deleteOldData();
+        } catch (const lmdb::error &e) {
+                nhlog::db()->error("saving sync response: {}", e.what());
+        }
+
+        emit trySyncCb();
+}
+
 void
 ChatPage::trySync()
 {
@@ -1072,7 +1111,14 @@ ChatPage::trySync()
         }
 
         http::client()->sync(
-          opts, [this](const mtx::responses::Sync &res, mtx::http::RequestErr err) {
+          opts,
+          [this, since = cache::nextBatchToken()](const mtx::responses::Sync &res,
+                                                  mtx::http::RequestErr err) {
+                  if (since != cache::nextBatchToken()) {
+                          nhlog::net()->warn("Duplicate sync, dropping");
+                          return;
+                  }
+
                   if (err) {
                           const auto error      = QString::fromStdString(err->matrix_error.error);
                           const auto msg        = tr("Please try to login again: %1").arg(error);
@@ -1094,40 +1140,7 @@ ChatPage::trySync()
                           return;
                   }
 
-                  nhlog::net()->debug("sync completed: {}", res.next_batch);
-
-                  // Ensure that we have enough one-time keys available.
-                  ensureOneTimeKeyCount(res.device_one_time_keys_count);
-
-                  // TODO: fine grained error handling
-                  try {
-                          cache::saveState(res);
-                          olm::handle_to_device_messages(res.to_device.events);
-
-                          auto updates = cache::roomUpdates(res);
-
-                          emit syncTopBar(updates);
-                          emit syncRoomlist(updates);
-
-                          emit syncUI(res.rooms);
-
-                          emit syncTags(cache::roomTagUpdates(res));
-
-                          // if we process a lot of syncs (1 every 200ms), this means we clean the
-                          // db every 100s
-                          static int syncCounter = 0;
-                          if (syncCounter++ >= 500) {
-                                  cache::deleteOldData();
-                                  syncCounter = 0;
-                          }
-                  } catch (const lmdb::map_full_error &e) {
-                          nhlog::db()->error("lmdb is full: {}", e.what());
-                          cache::deleteOldData();
-                  } catch (const lmdb::error &e) {
-                          nhlog::db()->error("saving sync response: {}", e.what());
-                  }
-
-                  emit trySyncCb();
+                  emit newSyncResponse(res);
           });
 }
 
diff --git a/src/ChatPage.h b/src/ChatPage.h
index fe63c9d9..ba1c56d1 100644
--- a/src/ChatPage.h
+++ b/src/ChatPage.h
@@ -140,6 +140,7 @@ signals:
         void trySyncCb();
         void tryDelayedSyncCb();
         void tryInitialSyncCb();
+        void newSyncResponse(mtx::responses::Sync res);
         void leftRoom(const QString &room_id);
 
         void initializeRoomList(QMap);
@@ -174,6 +175,7 @@ private slots:
 
         void joinRoom(const QString &room);
         void sendTypingNotifications();
+        void handleSyncResponse(mtx::responses::Sync res);
 
 private:
         static ChatPage *instance_;
diff --git a/src/CompletionModel.h b/src/CompletionModel.h
new file mode 100644
index 00000000..ed021051
--- /dev/null
+++ b/src/CompletionModel.h
@@ -0,0 +1,20 @@
+#pragma once
+
+// Class for showing a limited amount of completions at a time
+
+#include 
+
+class CompletionModel : public QSortFilterProxyModel
+{
+public:
+        CompletionModel(QAbstractItemModel *model, QObject *parent = nullptr)
+          : QSortFilterProxyModel(parent)
+        {
+                setSourceModel(model);
+        }
+        int rowCount(const QModelIndex &parent) const override
+        {
+                auto row_count = QSortFilterProxyModel::rowCount(parent);
+                return (row_count < 7) ? row_count : 7;
+        }
+};
diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp
index 7846737b..88612b14 100644
--- a/src/EventAccessors.cpp
+++ b/src/EventAccessors.cpp
@@ -248,6 +248,20 @@ struct EventInReplyTo
         }
 };
 
+struct EventRelatesTo
+{
+        template
+        using related_ev_id_t = decltype(Content::relates_to.event_id);
+        template
+        std::string operator()(const mtx::events::Event &e)
+        {
+                if constexpr (is_detected::value) {
+                        return e.content.relates_to.event_id;
+                }
+                return "";
+        }
+};
+
 struct EventTransactionId
 {
         template
@@ -409,6 +423,11 @@ mtx::accessors::in_reply_to_event(const mtx::events::collections::TimelineEvents
 {
         return std::visit(EventInReplyTo{}, event);
 }
+std::string
+mtx::accessors::relates_to_event_id(const mtx::events::collections::TimelineEvents &event)
+{
+        return std::visit(EventRelatesTo{}, event);
+}
 
 std::string
 mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event)
diff --git a/src/EventAccessors.h b/src/EventAccessors.h
index fa70f3eb..0cdc5f89 100644
--- a/src/EventAccessors.h
+++ b/src/EventAccessors.h
@@ -56,6 +56,8 @@ mimetype(const mtx::events::collections::TimelineEvents &event);
 std::string
 in_reply_to_event(const mtx::events::collections::TimelineEvents &event);
 std::string
+relates_to_event_id(const mtx::events::collections::TimelineEvents &event);
+std::string
 transaction_id(const mtx::events::collections::TimelineEvents &event);
 
 int64_t
diff --git a/src/Olm.cpp b/src/Olm.cpp
index 994a3a67..e38e9ef7 100644
--- a/src/Olm.cpp
+++ b/src/Olm.cpp
@@ -3,6 +3,7 @@
 #include "Olm.h"
 
 #include "Cache.h"
+#include "Cache_p.h"
 #include "Logging.h"
 #include "MatrixClient.h"
 #include "Utils.h"
@@ -316,32 +317,36 @@ send_key_request_for(const std::string &room_id,
         using namespace mtx::events;
 
         nhlog::crypto()->debug("sending key request: {}", json(e).dump(2));
-        auto payload = json{{"action", "request"},
-                            {"request_id", http::client()->generate_txn_id()},
-                            {"requesting_device_id", http::client()->device_id()},
-                            {"body",
-                             {{"algorithm", MEGOLM_ALGO},
-                              {"room_id", room_id},
-                              {"sender_key", e.content.sender_key},
-                              {"session_id", e.content.session_id}}}};
 
-        json body;
-        body["messages"][e.sender]                      = json::object();
-        body["messages"][e.sender][e.content.device_id] = payload;
+        mtx::events::msg::KeyRequest request;
+        request.action               = mtx::events::msg::RequestAction::Request;
+        request.algorithm            = MEGOLM_ALGO;
+        request.room_id              = room_id;
+        request.sender_key           = e.content.sender_key;
+        request.session_id           = e.content.session_id;
+        request.request_id           = "key_request." + http::client()->generate_txn_id();
+        request.requesting_device_id = http::client()->device_id();
 
-        nhlog::crypto()->debug("m.room_key_request: {}", body.dump(2));
+        nhlog::crypto()->debug("m.room_key_request: {}", json(request).dump(2));
 
-        http::client()->send_to_device("m.room_key_request", body, [e](mtx::http::RequestErr err) {
-                if (err) {
-                        nhlog::net()->warn("failed to send "
-                                           "send_to_device "
-                                           "message: {}",
-                                           err->matrix_error.error);
-                }
+        std::map> body;
+        body[mtx::identifiers::parse(e.sender)][e.content.device_id] =
+          request;
+        body[http::client()->user_id()]["*"] = request;
 
-                nhlog::net()->info(
-                  "m.room_key_request sent to {}:{}", e.sender, e.content.device_id);
-        });
+        http::client()->send_to_device(
+          http::client()->generate_txn_id(), body, [e](mtx::http::RequestErr err) {
+                  if (err) {
+                          nhlog::net()->warn("failed to send "
+                                             "send_to_device "
+                                             "message: {}",
+                                             err->matrix_error.error);
+                  }
+
+                  nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices",
+                                     e.sender,
+                                     e.content.device_id);
+          });
 }
 
 void
@@ -551,4 +556,50 @@ send_megolm_key_to_device(const std::string &user_id,
           });
 }
 
+DecryptionResult
+decryptEvent(const MegolmSessionIndex &index,
+             const mtx::events::EncryptedEvent &event)
+{
+        try {
+                if (!cache::client()->inboundMegolmSessionExists(index)) {
+                        return {DecryptionErrorCode::MissingSession, std::nullopt, std::nullopt};
+                }
+        } catch (const lmdb::error &e) {
+                return {DecryptionErrorCode::DbError, e.what(), std::nullopt};
+        }
+
+        // TODO: Lookup index,event_id,origin_server_ts tuple for replay attack errors
+        // TODO: Verify sender_key
+
+        std::string msg_str;
+        try {
+                auto session = cache::client()->getInboundMegolmSession(index);
+                auto res = olm::client()->decrypt_group_message(session, event.content.ciphertext);
+                msg_str  = std::string((char *)res.data.data(), res.data.size());
+        } catch (const lmdb::error &e) {
+                return {DecryptionErrorCode::DbError, e.what(), std::nullopt};
+        } catch (const mtx::crypto::olm_exception &e) {
+                return {DecryptionErrorCode::DecryptionFailed, e.what(), std::nullopt};
+        }
+
+        // Add missing fields for the event.
+        json body                = json::parse(msg_str);
+        body["event_id"]         = event.event_id;
+        body["sender"]           = event.sender;
+        body["origin_server_ts"] = event.origin_server_ts;
+        body["unsigned"]         = event.unsigned_data;
+
+        // relations are unencrypted in content...
+        if (json old_ev = event; old_ev["content"].count("m.relates_to") != 0)
+                body["content"]["m.relates_to"] = old_ev["content"]["m.relates_to"];
+
+        mtx::events::collections::TimelineEvent te;
+        try {
+                mtx::events::collections::from_json(body, te);
+        } catch (std::exception &e) {
+                return {DecryptionErrorCode::ParsingFailed, e.what(), std::nullopt};
+        }
+
+        return {std::nullopt, std::nullopt, std::move(te.data)};
+}
 } // namespace olm
diff --git a/src/Olm.h b/src/Olm.h
index 09038ad1..87f4e3ec 100644
--- a/src/Olm.h
+++ b/src/Olm.h
@@ -7,10 +7,30 @@
 #include 
 #include 
 
+#include 
+
 constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2";
 
 namespace olm {
 
+enum class DecryptionErrorCode
+{
+        MissingSession, // Session was not found, retrieve from backup or request from other devices
+                        // and try again
+        DbError,        // DB read failed
+        DecryptionFailed,   // libolm error
+        ParsingFailed,      // Failed to parse the actual event
+        ReplayAttack,       // Megolm index reused
+        UnknownFingerprint, // Unknown device Fingerprint
+};
+
+struct DecryptionResult
+{
+        std::optional error;
+        std::optional error_message;
+        std::optional event;
+};
+
 struct OlmMessage
 {
         std::string sender_key;
@@ -65,6 +85,10 @@ encrypt_group_message(const std::string &room_id,
                       const std::string &device_id,
                       nlohmann::json body);
 
+DecryptionResult
+decryptEvent(const MegolmSessionIndex &index,
+             const mtx::events::EncryptedEvent &event);
+
 void
 mark_keys_as_published();
 
diff --git a/src/TextInputWidget.cpp b/src/TextInputWidget.cpp
index a3392170..4a25c4cf 100644
--- a/src/TextInputWidget.cpp
+++ b/src/TextInputWidget.cpp
@@ -15,9 +15,11 @@
  * along with this program.  If not, see .
  */
 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -28,9 +30,12 @@
 
 #include "Cache.h"
 #include "ChatPage.h"
+#include "CompletionModel.h"
 #include "Logging.h"
 #include "TextInputWidget.h"
 #include "Utils.h"
+#include "emoji/EmojiSearchModel.h"
+#include "emoji/Provider.h"
 #include "ui/FlatButton.h"
 #include "ui/LoadingIndicator.h"
 
@@ -61,6 +66,23 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
         connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
         setAcceptRichText(false);
 
+        completer_ = new QCompleter(this);
+        completer_->setWidget(this);
+        auto model = new emoji::EmojiSearchModel(this);
+        model->sort(0, Qt::AscendingOrder);
+        completer_->setModel((emoji_completion_model_ = new CompletionModel(model, this)));
+        completer_->setModelSorting(QCompleter::UnsortedModel);
+        completer_->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+        completer_->popup()->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+
+        connect(completer_,
+                QOverload::of(&QCompleter::activated),
+                [this](auto &index) {
+                        emoji_popup_open_ = false;
+                        auto emoji        = index.data(emoji::EmojiModel::Unicode).toString();
+                        insertCompletion(emoji);
+                });
+
         typingTimer_ = new QTimer(this);
         typingTimer_->setInterval(1000);
         typingTimer_->setSingleShot(true);
@@ -101,6 +123,18 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
         previewDialog_.hide();
 }
 
+void
+FilteredTextEdit::insertCompletion(QString completion)
+{
+        // Paint the current word and replace it with 'completion'
+        auto cur_text = textAfterPosition(trigger_pos_);
+        auto tc       = textCursor();
+        tc.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, cur_text.length());
+        tc.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, cur_text.length());
+        tc.insertText(completion);
+        setTextCursor(tc);
+}
+
 void
 FilteredTextEdit::showResults(const std::vector &results)
 {
@@ -167,6 +201,21 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
                 }
         }
 
+        if (emoji_popup_open_) {
+                auto fake_key = (event->key() == Qt::Key_Backtab) ? Qt::Key_Up : Qt::Key_Down;
+                switch (event->key()) {
+                case Qt::Key_Backtab:
+                case Qt::Key_Tab: {
+                        // Simulate up/down arrow press
+                        auto ev = new QKeyEvent(QEvent::KeyPress, fake_key, Qt::NoModifier);
+                        QCoreApplication::postEvent(completer_->popup(), ev);
+                        return;
+                }
+                default:
+                        break;
+                }
+        }
+
         switch (event->key()) {
         case Qt::Key_At:
                 atTriggerPosition_ = textCursor().position();
@@ -195,8 +244,26 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
 
                 break;
         }
+        case Qt::Key_Colon: {
+                QTextEdit::keyPressEvent(event);
+                trigger_pos_ = textCursor().position() - 1;
+                emoji_completion_model_->setFilterRegExp("");
+                emoji_popup_open_ = true;
+                break;
+        }
         case Qt::Key_Return:
         case Qt::Key_Enter:
+                if (emoji_popup_open_) {
+                        if (!completer_->popup()->currentIndex().isValid()) {
+                                // No completion to select, do normal behavior
+                                completer_->popup()->hide();
+                                emoji_popup_open_ = false;
+                        } else {
+                                event->ignore();
+                                return;
+                        }
+                }
+
                 if (!(event->modifiers() & Qt::ShiftModifier)) {
                         stopTyping();
                         submit();
@@ -243,6 +310,21 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
                 if (isModifier)
                         return;
 
+                if (emoji_popup_open_ && textAfterPosition(trigger_pos_).length() > 2) {
+                        // Update completion
+                        emoji_completion_model_->setFilterRegExp(textAfterPosition(trigger_pos_));
+                        completer_->complete(completerRect());
+                }
+
+                if (emoji_popup_open_ && (completer_->completionCount() < 1 ||
+                                          !textAfterPosition(trigger_pos_)
+                                             .contains(QRegularExpression(":[^\r\n\t\f\v :]+$")))) {
+                        // No completions for this word or another word than the completer was
+                        // started with
+                        emoji_popup_open_ = false;
+                        completer_->popup()->hide();
+                }
+
                 if (textCursor().position() == 0) {
                         resetAnchor();
                         closeSuggestions();
@@ -352,6 +434,29 @@ FilteredTextEdit::stopTyping()
         emit stoppedTyping();
 }
 
+QRect
+FilteredTextEdit::completerRect()
+{
+        // Move left edge to the beginning of the word
+        auto cursor = textCursor();
+        auto rect   = cursorRect();
+        cursor.movePosition(
+          QTextCursor::Left, QTextCursor::MoveAnchor, textAfterPosition(trigger_pos_).length());
+        auto cursor_global_x  = viewport()->mapToGlobal(cursorRect(cursor).topLeft()).x();
+        auto rect_global_left = viewport()->mapToGlobal(rect.bottomLeft()).x();
+        auto dx               = qAbs(rect_global_left - cursor_global_x);
+        rect.moveLeft(rect.left() - dx);
+
+        auto item_height = completer_->popup()->sizeHintForRow(0);
+        auto max_height  = item_height * completer_->maxVisibleItems();
+        auto height      = (completer_->completionCount() > completer_->maxVisibleItems())
+                        ? max_height
+                        : completer_->completionCount() * item_height;
+        rect.setWidth(completer_->popup()->sizeHintForColumn(0));
+        rect.moveBottom(-height);
+        return rect;
+}
+
 QSize
 FilteredTextEdit::sizeHint() const
 {
@@ -581,27 +686,29 @@ void
 TextInputWidget::command(QString command, QString args)
 {
         if (command == "me") {
-                sendEmoteMessage(args);
+                emit sendEmoteMessage(args);
         } else if (command == "join") {
-                sendJoinRoomRequest(args);
+                emit sendJoinRoomRequest(args);
         } else if (command == "invite") {
-                sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
+                emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
         } else if (command == "kick") {
-                sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
+                emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
         } else if (command == "ban") {
-                sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
+                emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
         } else if (command == "unban") {
-                sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
+                emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
         } else if (command == "roomnick") {
-                changeRoomNick(args);
+                emit changeRoomNick(args);
         } else if (command == "shrug") {
-                sendTextMessage("¯\\_(ツ)_/¯");
+                emit sendTextMessage("¯\\_(ツ)_/¯");
         } else if (command == "fliptable") {
-                sendTextMessage("(╯°□°)╯︵ ┻━┻");
+                emit sendTextMessage("(╯°□°)╯︵ ┻━┻");
         } else if (command == "unfliptable") {
-                sendTextMessage(" ┯━┯╭( º _ º╭)");
+                emit sendTextMessage(" ┯━┯╭( º _ º╭)");
         } else if (command == "sovietflip") {
-                sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
+                emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
+        } else if (command == "clear-timeline") {
+                emit clearRoomTimeline();
         }
 }
 
@@ -633,7 +740,7 @@ TextInputWidget::showUploadSpinner()
         topLayout_->removeWidget(sendFileBtn_);
         sendFileBtn_->hide();
 
-        topLayout_->insertWidget(0, spinner_);
+        topLayout_->insertWidget(1, spinner_);
         spinner_->start();
 }
 
@@ -641,7 +748,7 @@ void
 TextInputWidget::hideUploadSpinner()
 {
         topLayout_->removeWidget(spinner_);
-        topLayout_->insertWidget(0, sendFileBtn_);
+        topLayout_->insertWidget(1, sendFileBtn_);
         sendFileBtn_->show();
         spinner_->stop();
 }
diff --git a/src/TextInputWidget.h b/src/TextInputWidget.h
index 27dff57f..3aa05c39 100644
--- a/src/TextInputWidget.h
+++ b/src/TextInputWidget.h
@@ -33,8 +33,10 @@
 
 struct SearchResult;
 
+class CompletionModel;
 class FlatButton;
 class LoadingIndicator;
+class QCompleter;
 
 class FilteredTextEdit : public QTextEdit
 {
@@ -80,8 +82,12 @@ protected:
         }
 
 private:
+        bool emoji_popup_open_ = false;
+        CompletionModel *emoji_completion_model_;
         std::deque true_history_, working_history_;
+        int trigger_pos_; // Where emoji completer was triggered
         size_t history_index_;
+        QCompleter *completer_;
         QTimer *typingTimer_;
 
         SuggestionsPopup suggestionsPopup_;
@@ -103,19 +109,27 @@ private:
         {
                 return pos == atTriggerPosition_ + anchorWidth(anchor);
         }
-
+        QRect completerRect();
         QString query()
         {
                 auto cursor = textCursor();
                 cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
                 return cursor.selectedText();
         }
+        QString textAfterPosition(int pos)
+        {
+                auto tc = textCursor();
+                tc.setPosition(pos);
+                tc.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
+                return tc.selectedText();
+        }
 
         dialogs::PreviewUploadOverlay previewDialog_;
 
         //! Latest position of the '@' character that triggers the username completer.
         int atTriggerPosition_ = -1;
 
+        void insertCompletion(QString completion);
         void textChanged();
         void uploadData(const QByteArray data, const QString &media, const QString &filename);
         void afterCompletion(int);
@@ -158,6 +172,7 @@ private slots:
 signals:
         void sendTextMessage(const QString &msg);
         void sendEmoteMessage(QString msg);
+        void clearRoomTimeline();
         void heightChanged(int height);
 
         void uploadMedia(const QSharedPointer data,
diff --git a/src/WebRTCSession.cpp b/src/WebRTCSession.cpp
index 2248fb1a..e9822f7d 100644
--- a/src/WebRTCSession.cpp
+++ b/src/WebRTCSession.cpp
@@ -176,7 +176,7 @@ createAnswer(GstPromise *promise, gpointer webrtc)
         g_signal_emit_by_name(webrtc, "create-answer", nullptr, promise);
 }
 
-#if GST_CHECK_VERSION(1, 17, 0)
+#if GST_CHECK_VERSION(1, 18, 0)
 void
 iceGatheringStateChanged(GstElement *webrtc,
                          GParamSpec *pspec G_GNUC_UNUSED,
@@ -223,6 +223,10 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
 {
         nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate);
 
+#if GST_CHECK_VERSION(1, 18, 0)
+        localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
+        return;
+#else
         if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) {
                 emit WebRTCSession::instance().newICECandidate(
                   {"audio", (uint16_t)mlineIndex, candidate});
@@ -232,9 +236,8 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
         localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
 
         // GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers
-        // GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.17.
+        // GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.18.
         // Use a 100ms timeout in the meantime
-#if !GST_CHECK_VERSION(1, 17, 0)
         static guint timerid = 0;
         if (timerid)
                 g_source_remove(timerid);
@@ -282,11 +285,11 @@ linkNewPad(GstElement *decodebin G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe
                 GstElement *resample = gst_element_factory_make("audioresample", nullptr);
                 GstElement *sink     = gst_element_factory_make("autoaudiosink", nullptr);
                 gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr);
+                gst_element_link_many(queue, convert, resample, sink, nullptr);
                 gst_element_sync_state_with_parent(queue);
                 gst_element_sync_state_with_parent(convert);
                 gst_element_sync_state_with_parent(resample);
                 gst_element_sync_state_with_parent(sink);
-                gst_element_link_many(queue, convert, resample, sink, nullptr);
                 queuepad = gst_element_get_static_pad(queue, "sink");
         }
 
@@ -423,8 +426,12 @@ WebRTCSession::acceptICECandidates(
                 for (const auto &c : candidates) {
                         nhlog::ui()->debug(
                           "WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate);
-                        g_signal_emit_by_name(
-                          webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str());
+                        if (!c.candidate.empty()) {
+                                g_signal_emit_by_name(webrtc_,
+                                                      "add-ice-candidate",
+                                                      c.sdpMLineIndex,
+                                                      c.candidate.c_str());
+                        }
                 }
         }
 }
@@ -471,7 +478,7 @@ WebRTCSession::startPipeline(int opusPayloadType)
         gst_element_set_state(pipe_, GST_STATE_READY);
         g_signal_connect(webrtc_, "pad-added", G_CALLBACK(addDecodeBin), pipe_);
 
-#if GST_CHECK_VERSION(1, 17, 0)
+#if GST_CHECK_VERSION(1, 18, 0)
         // capture ICE gathering completion
         g_signal_connect(
           webrtc_, "notify::ice-gathering-state", G_CALLBACK(iceGatheringStateChanged), nullptr);
diff --git a/src/dialogs/RoomSettings.cpp b/src/dialogs/RoomSettings.cpp
index 26aece32..822b7218 100644
--- a/src/dialogs/RoomSettings.cpp
+++ b/src/dialogs/RoomSettings.cpp
@@ -151,7 +151,7 @@ EditModal::applyClicked()
                 state::Name body;
                 body.name = newName.toStdString();
 
-                http::client()->send_state_event(
+                http::client()->send_state_event(
                   roomId_.toStdString(),
                   body,
                   [proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@@ -169,7 +169,7 @@ EditModal::applyClicked()
                 state::Topic body;
                 body.topic = newTopic.toStdString();
 
-                http::client()->send_state_event(
+                http::client()->send_state_event(
                   roomId_.toStdString(),
                   body,
                   [proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@@ -694,7 +694,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
         startLoadingSpinner();
         resetErrorLabel();
 
-        http::client()->send_state_event(
+        http::client()->send_state_event(
           room_id,
           join_rule,
           [this, room_id, guest_access](const mtx::responses::EventId &,
@@ -708,7 +708,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
                           return;
                   }
 
-                  http::client()->send_state_event(
+                  http::client()->send_state_event(
                     room_id,
                     guest_access,
                     [this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@@ -843,7 +843,7 @@ RoomSettings::updateAvatar()
                   avatar_event.image_info.size     = size;
                   avatar_event.url                 = res.content_uri;
 
-                  http::client()->send_state_event(
+                  http::client()->send_state_event(
                     room_id,
                     avatar_event,
                     [content = std::move(content), proxy = std::move(proxy)](
diff --git a/src/emoji/EmojiSearchModel.h b/src/emoji/EmojiSearchModel.h
new file mode 100644
index 00000000..1ff5f4e9
--- /dev/null
+++ b/src/emoji/EmojiSearchModel.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include "EmojiModel.h"
+
+#include 
+#include 
+#include 
+
+namespace emoji {
+
+// Map emoji data to searchable data
+class EmojiSearchModel : public QSortFilterProxyModel
+{
+public:
+        EmojiSearchModel(QObject *parent = nullptr)
+          : QSortFilterProxyModel(parent)
+        {
+                setSourceModel(new EmojiModel(this));
+        }
+        QVariant data(const QModelIndex &index, int role = Qt::UserRole + 1) const override
+        {
+                if (role == Qt::DisplayRole) {
+                        auto emoji = QSortFilterProxyModel::data(index, role).toString();
+                        return emoji + " :" +
+                               toShortcode(data(index, EmojiModel::ShortName).toString()) + ":";
+                }
+                return QSortFilterProxyModel::data(index, role);
+        }
+
+private:
+        QString toShortcode(QString shortname) const
+        {
+                return shortname.replace(" ", "-").replace(":", "-").replace("--", "-").toLower();
+        }
+};
+
+}
diff --git a/src/main.cpp b/src/main.cpp
index 46691e6f..e02ffa36 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -173,11 +173,12 @@ main(int argc, char *argv[])
         QString lang = QLocale::system().name();
 
         QTranslator qtTranslator;
-        qtTranslator.load("qt_" + lang, QLibraryInfo::location(QLibraryInfo::TranslationsPath));
+        qtTranslator.load(
+          QLocale(), "qt", "_", QLibraryInfo::location(QLibraryInfo::TranslationsPath));
         app.installTranslator(&qtTranslator);
 
         QTranslator appTranslator;
-        appTranslator.load("nheko_" + lang, ":/translations");
+        appTranslator.load(QLocale(), "nheko", "_", ":/translations");
         app.installTranslator(&appTranslator);
 
         MainWindow w;
diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
new file mode 100644
index 00000000..20487b28
--- /dev/null
+++ b/src/timeline/EventStore.cpp
@@ -0,0 +1,570 @@
+#include "EventStore.h"
+
+#include 
+#include 
+
+#include "Cache.h"
+#include "Cache_p.h"
+#include "EventAccessors.h"
+#include "Logging.h"
+#include "MatrixClient.h"
+#include "Olm.h"
+
+Q_DECLARE_METATYPE(Reaction)
+
+QCache EventStore::decryptedEvents_{
+  1000};
+QCache EventStore::events_by_id_{
+  1000};
+QCache EventStore::events_{1000};
+
+EventStore::EventStore(std::string room_id, QObject *)
+  : room_id_(std::move(room_id))
+{
+        static auto reactionType = qRegisterMetaType();
+        (void)reactionType;
+
+        auto range = cache::client()->getTimelineRange(room_id_);
+
+        if (range) {
+                this->first = range->first;
+                this->last  = range->last;
+        }
+
+        connect(
+          this,
+          &EventStore::eventFetched,
+          this,
+          [this](std::string id,
+                 std::string relatedTo,
+                 mtx::events::collections::TimelineEvents timeline) {
+                  cache::client()->storeEvent(room_id_, id, {timeline});
+
+                  if (!relatedTo.empty()) {
+                          auto idx = idToIndex(relatedTo);
+                          if (idx)
+                                  emit dataChanged(*idx, *idx);
+                  }
+          },
+          Qt::QueuedConnection);
+
+        connect(
+          this,
+          &EventStore::oldMessagesRetrieved,
+          this,
+          [this](const mtx::responses::Messages &res) {
+                  //
+                  uint64_t newFirst = cache::client()->saveOldMessages(room_id_, res);
+                  if (newFirst == first && !res.chunk.empty())
+                          fetchMore();
+                  else {
+                          emit beginInsertRows(toExternalIdx(newFirst),
+                                               toExternalIdx(this->first - 1));
+                          this->first = newFirst;
+                          emit endInsertRows();
+                          emit fetchedMore();
+                  }
+          },
+          Qt::QueuedConnection);
+
+        connect(this, &EventStore::processPending, this, [this]() {
+                if (!current_txn.empty()) {
+                        nhlog::ui()->debug("Already processing {}", current_txn);
+                        return;
+                }
+
+                auto event = cache::client()->firstPendingMessage(room_id_);
+
+                if (!event) {
+                        nhlog::ui()->debug("No event to send");
+                        return;
+                }
+
+                std::visit(
+                  [this](auto e) {
+                          auto txn_id       = e.event_id;
+                          this->current_txn = txn_id;
+
+                          if (txn_id.empty() || txn_id[0] != 'm') {
+                                  nhlog::ui()->debug("Invalid txn id '{}'", txn_id);
+                                  cache::client()->removePendingStatus(room_id_, txn_id);
+                                  return;
+                          }
+
+                          if constexpr (mtx::events::message_content_to_type !=
+                                        mtx::events::EventType::Unsupported)
+                                  http::client()->send_room_message(
+                                    room_id_,
+                                    txn_id,
+                                    e.content,
+                                    [this, txn_id](const mtx::responses::EventId &event_id,
+                                                   mtx::http::RequestErr err) {
+                                            if (err) {
+                                                    const int status_code =
+                                                      static_cast(err->status_code);
+                                                    nhlog::net()->warn(
+                                                      "[{}] failed to send message: {} {}",
+                                                      txn_id,
+                                                      err->matrix_error.error,
+                                                      status_code);
+                                                    emit messageFailed(txn_id);
+                                                    return;
+                                            }
+                                            emit messageSent(txn_id, event_id.event_id.to_string());
+                                    });
+                  },
+                  event->data);
+        });
+
+        connect(
+          this,
+          &EventStore::messageFailed,
+          this,
+          [this](std::string txn_id) {
+                  if (current_txn == txn_id) {
+                          current_txn_error_count++;
+                          if (current_txn_error_count > 10) {
+                                  nhlog::ui()->debug("failing txn id '{}'", txn_id);
+                                  cache::client()->removePendingStatus(room_id_, txn_id);
+                                  current_txn_error_count = 0;
+                          }
+                  }
+                  QTimer::singleShot(1000, this, [this]() {
+                          nhlog::ui()->debug("timeout");
+                          this->current_txn = "";
+                          emit processPending();
+                  });
+          },
+          Qt::QueuedConnection);
+
+        connect(
+          this,
+          &EventStore::messageSent,
+          this,
+          [this](std::string txn_id, std::string event_id) {
+                  nhlog::ui()->debug("sent {}", txn_id);
+
+                  http::client()->read_event(
+                    room_id_, event_id, [this, event_id](mtx::http::RequestErr err) {
+                            if (err) {
+                                    nhlog::net()->warn(
+                                      "failed to read_event ({}, {})", room_id_, event_id);
+                            }
+                    });
+
+                  cache::client()->removePendingStatus(room_id_, txn_id);
+                  this->current_txn             = "";
+                  this->current_txn_error_count = 0;
+                  emit processPending();
+          },
+          Qt::QueuedConnection);
+}
+
+void
+EventStore::addPending(mtx::events::collections::TimelineEvents event)
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        cache::client()->savePendingMessage(this->room_id_, {event});
+        mtx::responses::Timeline events;
+        events.limited = false;
+        events.events.emplace_back(event);
+        handleSync(events);
+
+        emit processPending();
+}
+
+void
+EventStore::clearTimeline()
+{
+        emit beginResetModel();
+
+        cache::client()->clearTimeline(room_id_);
+        auto range = cache::client()->getTimelineRange(room_id_);
+        if (range) {
+                nhlog::db()->info("Range {} {}", range->last, range->first);
+                this->last  = range->last;
+                this->first = range->first;
+        } else {
+                this->first = std::numeric_limits::max();
+                this->last  = std::numeric_limits::max();
+        }
+        nhlog::ui()->info("Range {} {}", this->last, this->first);
+
+        emit endResetModel();
+}
+
+void
+EventStore::handleSync(const mtx::responses::Timeline &events)
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        auto range = cache::client()->getTimelineRange(room_id_);
+        if (!range)
+                return;
+
+        if (events.limited) {
+                emit beginResetModel();
+                this->last  = range->last;
+                this->first = range->first;
+                emit endResetModel();
+
+        } else if (range->last > this->last) {
+                emit beginInsertRows(toExternalIdx(this->last + 1), toExternalIdx(range->last));
+                this->last = range->last;
+                emit endInsertRows();
+        }
+
+        for (const auto &event : events.events) {
+                std::string relates_to;
+                if (auto redaction =
+                      std::get_if>(
+                        &event)) {
+                        // fixup reactions
+                        auto redacted = events_by_id_.object({room_id_, redaction->redacts});
+                        if (redacted) {
+                                auto id = mtx::accessors::relates_to_event_id(*redacted);
+                                if (!id.empty()) {
+                                        auto idx = idToIndex(id);
+                                        if (idx) {
+                                                events_by_id_.remove(
+                                                  {room_id_, redaction->redacts});
+                                                events_.remove({room_id_, toInternalIdx(*idx)});
+                                                emit dataChanged(*idx, *idx);
+                                        }
+                                }
+                        }
+
+                        relates_to = redaction->redacts;
+                } else if (auto reaction =
+                             std::get_if>(
+                               &event)) {
+                        relates_to = reaction->content.relates_to.event_id;
+                } else {
+                        relates_to = mtx::accessors::in_reply_to_event(event);
+                }
+
+                if (!relates_to.empty()) {
+                        auto idx = cache::client()->getTimelineIndex(room_id_, relates_to);
+                        if (idx) {
+                                events_by_id_.remove({room_id_, relates_to});
+                                decryptedEvents_.remove({room_id_, relates_to});
+                                events_.remove({room_id_, *idx});
+                                emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx));
+                        }
+                }
+
+                if (auto txn_id = mtx::accessors::transaction_id(event); !txn_id.empty()) {
+                        auto idx = cache::client()->getTimelineIndex(
+                          room_id_, mtx::accessors::event_id(event));
+                        if (idx) {
+                                Index index{room_id_, *idx};
+                                events_.remove(index);
+                                emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx));
+                        }
+                }
+        }
+}
+
+QVariantList
+EventStore::reactions(const std::string &event_id)
+{
+        auto event_ids = cache::client()->relatedEvents(room_id_, event_id);
+
+        struct TempReaction
+        {
+                int count = 0;
+                std::vector users;
+                std::string reactedBySelf;
+        };
+        std::map aggregation;
+        std::vector reactions;
+
+        auto self = http::client()->user_id().to_string();
+        for (const auto &id : event_ids) {
+                auto related_event = get(id, event_id);
+                if (!related_event)
+                        continue;
+
+                if (auto reaction = std::get_if>(
+                      related_event)) {
+                        auto &agg = aggregation[reaction->content.relates_to.key];
+
+                        if (agg.count == 0) {
+                                Reaction temp{};
+                                temp.key_ =
+                                  QString::fromStdString(reaction->content.relates_to.key);
+                                reactions.push_back(temp);
+                        }
+
+                        agg.count++;
+                        agg.users.push_back(cache::displayName(room_id_, reaction->sender));
+                        if (reaction->sender == self)
+                                agg.reactedBySelf = reaction->event_id;
+                }
+        }
+
+        QVariantList temp;
+        for (auto &reaction : reactions) {
+                const auto &agg            = aggregation[reaction.key_.toStdString()];
+                reaction.count_            = agg.count;
+                reaction.selfReactedEvent_ = QString::fromStdString(agg.reactedBySelf);
+
+                bool firstReaction = true;
+                for (const auto &user : agg.users) {
+                        if (firstReaction)
+                                firstReaction = false;
+                        else
+                                reaction.users_ += ", ";
+
+                        reaction.users_ += QString::fromStdString(user);
+                }
+
+                nhlog::db()->debug("key: {}, count: {}, users: {}",
+                                   reaction.key_.toStdString(),
+                                   reaction.count_,
+                                   reaction.users_.toStdString());
+                temp.append(QVariant::fromValue(reaction));
+        }
+
+        return temp;
+}
+
+mtx::events::collections::TimelineEvents *
+EventStore::get(int idx, bool decrypt)
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        Index index{room_id_, toInternalIdx(idx)};
+        if (index.idx > last || index.idx < first)
+                return nullptr;
+
+        auto event_ptr = events_.object(index);
+        if (!event_ptr) {
+                auto event_id = cache::client()->getTimelineEventId(room_id_, index.idx);
+                if (!event_id)
+                        return nullptr;
+
+                auto event = cache::client()->getEvent(room_id_, *event_id);
+                if (!event)
+                        return nullptr;
+                else
+                        event_ptr =
+                          new mtx::events::collections::TimelineEvents(std::move(event->data));
+                events_.insert(index, event_ptr);
+        }
+
+        if (decrypt)
+                if (auto encrypted =
+                      std::get_if>(
+                        event_ptr))
+                        return decryptEvent({room_id_, encrypted->event_id}, *encrypted);
+
+        return event_ptr;
+}
+
+std::optional
+EventStore::idToIndex(std::string_view id) const
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        auto idx = cache::client()->getTimelineIndex(room_id_, id);
+        if (idx)
+                return toExternalIdx(*idx);
+        else
+                return std::nullopt;
+}
+std::optional
+EventStore::indexToId(int idx) const
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        return cache::client()->getTimelineEventId(room_id_, toInternalIdx(idx));
+}
+
+mtx::events::collections::TimelineEvents *
+EventStore::decryptEvent(const IdIndex &idx,
+                         const mtx::events::EncryptedEvent &e)
+{
+        if (auto cachedEvent = decryptedEvents_.object(idx))
+                return cachedEvent;
+
+        MegolmSessionIndex index;
+        index.room_id    = room_id_;
+        index.session_id = e.content.session_id;
+        index.sender_key = e.content.sender_key;
+
+        auto asCacheEntry = [&idx](mtx::events::collections::TimelineEvents &&event) {
+                auto event_ptr = new mtx::events::collections::TimelineEvents(std::move(event));
+                decryptedEvents_.insert(idx, event_ptr);
+                return event_ptr;
+        };
+
+        auto decryptionResult = olm::decryptEvent(index, e);
+
+        if (decryptionResult.error) {
+                mtx::events::RoomEvent dummy;
+                dummy.origin_server_ts = e.origin_server_ts;
+                dummy.event_id         = e.event_id;
+                dummy.sender           = e.sender;
+                switch (*decryptionResult.error) {
+                case olm::DecryptionErrorCode::MissingSession:
+                        dummy.content.body =
+                          tr("-- Encrypted Event (No keys found for decryption) --",
+                             "Placeholder, when the message was not decrypted yet or can't be "
+                             "decrypted.")
+                            .toStdString();
+                        nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})",
+                                              index.room_id,
+                                              index.session_id,
+                                              e.sender);
+                        // TODO: Check if this actually works and look in key backup
+                        olm::send_key_request_for(room_id_, e);
+                        break;
+                case olm::DecryptionErrorCode::DbError:
+                        nhlog::db()->critical(
+                          "failed to retrieve megolm session with index ({}, {}, {})",
+                          index.room_id,
+                          index.session_id,
+                          index.sender_key,
+                          decryptionResult.error_message.value_or(""));
+                        dummy.content.body =
+                          tr("-- Decryption Error (failed to retrieve megolm keys from db) --",
+                             "Placeholder, when the message can't be decrypted, because the DB "
+                             "access "
+                             "failed.")
+                            .toStdString();
+                        break;
+                case olm::DecryptionErrorCode::DecryptionFailed:
+                        nhlog::crypto()->critical(
+                          "failed to decrypt message with index ({}, {}, {}): {}",
+                          index.room_id,
+                          index.session_id,
+                          index.sender_key,
+                          decryptionResult.error_message.value_or(""));
+                        dummy.content.body =
+                          tr("-- Decryption Error (%1) --",
+                             "Placeholder, when the message can't be decrypted. In this case, the "
+                             "Olm "
+                             "decrytion returned an error, which is passed as %1.")
+                            .arg(
+                              QString::fromStdString(decryptionResult.error_message.value_or("")))
+                            .toStdString();
+                        break;
+                case olm::DecryptionErrorCode::ParsingFailed:
+                        dummy.content.body =
+                          tr("-- Encrypted Event (Unknown event type) --",
+                             "Placeholder, when the message was decrypted, but we couldn't parse "
+                             "it, because "
+                             "Nheko/mtxclient don't support that event type yet.")
+                            .toStdString();
+                        break;
+                case olm::DecryptionErrorCode::ReplayAttack:
+                        nhlog::crypto()->critical(
+                          "Reply attack while decryptiong event {} in room {} from {}!",
+                          e.event_id,
+                          room_id_,
+                          index.sender_key);
+                        dummy.content.body =
+                          tr("-- Reply attack! This message index was reused! --").toStdString();
+                        break;
+                case olm::DecryptionErrorCode::UnknownFingerprint:
+                        // TODO: don't fail, just show in UI.
+                        nhlog::crypto()->critical("Message by unverified fingerprint {}",
+                                                  index.sender_key);
+                        dummy.content.body =
+                          tr("-- Message by unverified device! --").toStdString();
+                        break;
+                }
+                return asCacheEntry(std::move(dummy));
+        }
+
+        auto encInfo = mtx::accessors::file(decryptionResult.event.value());
+        if (encInfo)
+                emit newEncryptedImage(encInfo.value());
+
+        return asCacheEntry(std::move(decryptionResult.event.value()));
+}
+
+mtx::events::collections::TimelineEvents *
+EventStore::get(std::string_view id, std::string_view related_to, bool decrypt)
+{
+        if (this->thread() != QThread::currentThread())
+                nhlog::db()->warn("{} called from a different thread!", __func__);
+
+        if (id.empty())
+                return nullptr;
+
+        IdIndex index{room_id_, std::string(id.data(), id.size())};
+
+        auto event_ptr = events_by_id_.object(index);
+        if (!event_ptr) {
+                auto event = cache::client()->getEvent(room_id_, index.id);
+                if (!event) {
+                        http::client()->get_event(
+                          room_id_,
+                          index.id,
+                          [this,
+                           relatedTo = std::string(related_to.data(), related_to.size()),
+                           id = index.id](const mtx::events::collections::TimelineEvents &timeline,
+                                          mtx::http::RequestErr err) {
+                                  if (err) {
+                                          nhlog::net()->error(
+                                            "Failed to retrieve event with id {}, which was "
+                                            "requested to show the replyTo for event {}",
+                                            relatedTo,
+                                            id);
+                                          return;
+                                  }
+                                  emit eventFetched(id, relatedTo, timeline);
+                          });
+                        return nullptr;
+                }
+                event_ptr = new mtx::events::collections::TimelineEvents(std::move(event->data));
+                events_by_id_.insert(index, event_ptr);
+        }
+
+        if (decrypt)
+                if (auto encrypted =
+                      std::get_if>(
+                        event_ptr))
+                        return decryptEvent(index, *encrypted);
+
+        return event_ptr;
+}
+
+void
+EventStore::fetchMore()
+{
+        mtx::http::MessagesOpts opts;
+        opts.room_id = room_id_;
+        opts.from    = cache::client()->previousBatchToken(room_id_);
+
+        nhlog::ui()->debug("Paginating room {}, token {}", opts.room_id, opts.from);
+
+        http::client()->messages(
+          opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) {
+                  if (cache::client()->previousBatchToken(room_id_) != opts.from) {
+                          nhlog::net()->warn("Cache cleared while fetching more messages, dropping "
+                                             "/messages response");
+                          emit fetchedMore();
+                          return;
+                  }
+                  if (err) {
+                          nhlog::net()->error("failed to call /messages ({}): {} - {} - {}",
+                                              opts.room_id,
+                                              mtx::errors::to_string(err->matrix_error.errcode),
+                                              err->matrix_error.error,
+                                              err->parse_error);
+                          emit fetchedMore();
+                          return;
+                  }
+
+                  emit oldMessagesRetrieved(std::move(res));
+          });
+}
diff --git a/src/timeline/EventStore.h b/src/timeline/EventStore.h
new file mode 100644
index 00000000..d4353a18
--- /dev/null
+++ b/src/timeline/EventStore.h
@@ -0,0 +1,122 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include "Reaction.h"
+
+class EventStore : public QObject
+{
+        Q_OBJECT
+
+public:
+        EventStore(std::string room_id, QObject *parent);
+
+        struct Index
+        {
+                std::string room;
+                uint64_t idx;
+
+                friend uint qHash(const Index &i, uint seed = 0) noexcept
+                {
+                        QtPrivate::QHashCombine hash;
+                        seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size()));
+                        seed = hash(seed, i.idx);
+                        return seed;
+                }
+
+                friend bool operator==(const Index &a, const Index &b) noexcept
+                {
+                        return a.idx == b.idx && a.room == b.room;
+                }
+        };
+        struct IdIndex
+        {
+                std::string room, id;
+
+                friend uint qHash(const IdIndex &i, uint seed = 0) noexcept
+                {
+                        QtPrivate::QHashCombine hash;
+                        seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size()));
+                        seed = hash(seed, QByteArray::fromRawData(i.id.data(), i.id.size()));
+                        return seed;
+                }
+
+                friend bool operator==(const IdIndex &a, const IdIndex &b) noexcept
+                {
+                        return a.id == b.id && a.room == b.room;
+                }
+        };
+
+        void fetchMore();
+        void handleSync(const mtx::responses::Timeline &events);
+
+        // optionally returns the event or nullptr and fetches it, after which it emits a
+        // relatedFetched event
+        mtx::events::collections::TimelineEvents *get(std::string_view id,
+                                                      std::string_view related_to,
+                                                      bool decrypt = true);
+        // always returns a proper event as long as the idx is valid
+        mtx::events::collections::TimelineEvents *get(int idx, bool decrypt = true);
+
+        QVariantList reactions(const std::string &event_id);
+
+        int size() const
+        {
+                return last != std::numeric_limits::max()
+                         ? static_cast(last - first) + 1
+                         : 0;
+        }
+        int toExternalIdx(uint64_t idx) const { return static_cast(idx - first); }
+        uint64_t toInternalIdx(int idx) const { return first + idx; }
+
+        std::optional idToIndex(std::string_view id) const;
+        std::optional indexToId(int idx) const;
+
+signals:
+        void beginInsertRows(int from, int to);
+        void endInsertRows();
+        void beginResetModel();
+        void endResetModel();
+        void dataChanged(int from, int to);
+        void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
+        void eventFetched(std::string id,
+                          std::string relatedTo,
+                          mtx::events::collections::TimelineEvents timeline);
+        void oldMessagesRetrieved(const mtx::responses::Messages &);
+        void fetchedMore();
+
+        void processPending();
+        void messageSent(std::string txn_id, std::string event_id);
+        void messageFailed(std::string txn_id);
+
+public slots:
+        void addPending(mtx::events::collections::TimelineEvents event);
+        void clearTimeline();
+
+private:
+        mtx::events::collections::TimelineEvents *decryptEvent(
+          const IdIndex &idx,
+          const mtx::events::EncryptedEvent &e);
+
+        std::string room_id_;
+
+        uint64_t first = std::numeric_limits::max(),
+                 last  = std::numeric_limits::max();
+
+        static QCache decryptedEvents_;
+        static QCache events_;
+        static QCache events_by_id_;
+
+        std::string current_txn;
+        int current_txn_error_count = 0;
+};
diff --git a/src/timeline/Reaction.cpp b/src/timeline/Reaction.cpp
new file mode 100644
index 00000000..343c4649
--- /dev/null
+++ b/src/timeline/Reaction.cpp
@@ -0,0 +1 @@
+#include "Reaction.h"
diff --git a/src/timeline/Reaction.h b/src/timeline/Reaction.h
new file mode 100644
index 00000000..5f122e0a
--- /dev/null
+++ b/src/timeline/Reaction.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include 
+#include 
+
+struct Reaction
+{
+        Q_GADGET
+        Q_PROPERTY(QString key READ key)
+        Q_PROPERTY(QString users READ users)
+        Q_PROPERTY(QString selfReactedEvent READ selfReactedEvent)
+        Q_PROPERTY(int count READ count)
+
+public:
+        QString key() const { return key_; }
+        QString users() const { return users_; }
+        QString selfReactedEvent() const { return selfReactedEvent_; }
+        int count() const { return count_; }
+
+        QString key_;
+        QString users_;
+        QString selfReactedEvent_;
+        int count_;
+};
diff --git a/src/timeline/ReactionsModel.cpp b/src/timeline/ReactionsModel.cpp
deleted file mode 100644
index 1200e2ba..00000000
--- a/src/timeline/ReactionsModel.cpp
+++ /dev/null
@@ -1,98 +0,0 @@
-#include "ReactionsModel.h"
-
-#include 
-#include 
-
-QHash
-ReactionsModel::roleNames() const
-{
-        return {
-          {Key, "key"},
-          {Count, "counter"},
-          {Users, "users"},
-          {SelfReactedEvent, "selfReactedEvent"},
-        };
-}
-
-int
-ReactionsModel::rowCount(const QModelIndex &) const
-{
-        return static_cast(reactions.size());
-}
-
-QVariant
-ReactionsModel::data(const QModelIndex &index, int role) const
-{
-        const int i = index.row();
-        if (i < 0 || i >= static_cast(reactions.size()))
-                return {};
-
-        switch (role) {
-        case Key:
-                return QString::fromStdString(reactions[i].key);
-        case Count:
-                return static_cast(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 SelfReactedEvent:
-                for (const auto &reaction : reactions[i].reactions)
-                        if (reaction.second.sender == http::client()->user_id().to_string())
-                                return QString::fromStdString(reaction.second.event_id);
-                return QStringLiteral("");
-        default:
-                return {};
-        }
-}
-
-void
-ReactionsModel::addReaction(const std::string &room_id,
-                            const mtx::events::RoomEvent &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 &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
deleted file mode 100644
index c839afc8..00000000
--- a/src/timeline/ReactionsModel.h
+++ /dev/null
@@ -1,41 +0,0 @@
-#pragma once
-
-#include 
-#include 
-
-#include 
-#include 
-
-#include 
-
-class ReactionsModel : public QAbstractListModel
-{
-        Q_OBJECT
-public:
-        explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); }
-        enum Roles
-        {
-                Key,
-                Count,
-                Users,
-                SelfReactedEvent,
-        };
-
-        QHash 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 &reaction);
-        void removeReaction(const mtx::events::RoomEvent &reaction);
-
-private:
-        struct KeyReaction
-        {
-                std::string key;
-                std::map> reactions;
-        };
-        std::string room_id_;
-        std::vector reactions;
-};
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 67e07d7b..b6c2d4bb 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -136,6 +136,11 @@ struct RoomEventType
         {
                 return qml_mtx_events::EventType::CallHangUp;
         }
+        qml_mtx_events::EventType operator()(
+          const mtx::events::Event &)
+        {
+                return qml_mtx_events::EventType::CallCandidates;
+        }
         // ::EventType::Type operator()(const Event &e) { return
         // ::EventType::LocationMessage; }
 };
@@ -156,70 +161,10 @@ toRoomEventTypeString(const mtx::events::collections::TimelineEvents &event)
 
 TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObject *parent)
   : QAbstractListModel(parent)
+  , events(room_id.toStdString(), this)
   , room_id_(room_id)
   , manager_(manager)
 {
-        connect(this,
-                &TimelineModel::oldMessagesRetrieved,
-                this,
-                &TimelineModel::addBackwardsEvents,
-                Qt::QueuedConnection);
-        connect(
-          this,
-          &TimelineModel::messageFailed,
-          this,
-          [this](QString txn_id) {
-                  nhlog::ui()->error("Failed to send {}, retrying", txn_id.toStdString());
-
-                  QTimer::singleShot(5000, this, [this]() { emit nextPendingMessage(); });
-          },
-          Qt::QueuedConnection);
-        connect(
-          this,
-          &TimelineModel::messageSent,
-          this,
-          [this](QString txn_id, QString event_id) {
-                  pending.removeOne(txn_id);
-
-                  auto ev = events.value(txn_id);
-
-                  if (auto reaction =
-                        std::get_if>(&ev)) {
-                          QString reactedTo =
-                            QString::fromStdString(reaction->content.relates_to.event_id);
-                          auto &rModel = reactions[reactedTo];
-                          rModel.removeReaction(*reaction);
-                          auto rCopy     = *reaction;
-                          rCopy.event_id = event_id.toStdString();
-                          rModel.addReaction(room_id_.toStdString(), rCopy);
-                  }
-
-                  int idx = idToIndex(txn_id);
-                  if (idx < 0) {
-                          // transaction already received via sync
-                          return;
-                  }
-                  eventOrder[idx] = event_id;
-                  ev              = std::visit(
-                    [event_id](const auto &e) -> mtx::events::collections::TimelineEvents {
-                            auto eventCopy     = e;
-                            eventCopy.event_id = event_id.toStdString();
-                            return eventCopy;
-                    },
-                    ev);
-
-                  events.remove(txn_id);
-                  events.insert(event_id, ev);
-
-                  // mark our messages as read
-                  readEvent(event_id.toStdString());
-
-                  emit dataChanged(index(idx, 0), index(idx, 0));
-
-                  if (pending.size() > 0)
-                          emit nextPendingMessage();
-          },
-          Qt::QueuedConnection);
         connect(
           this,
           &TimelineModel::redactionFailed,
@@ -227,28 +172,45 @@ TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObj
           [](const QString &msg) { emit ChatPage::instance()->showNotification(msg); },
           Qt::QueuedConnection);
 
-        connect(this,
-                &TimelineModel::nextPendingMessage,
-                this,
-                &TimelineModel::processOnePendingMessage,
-                Qt::QueuedConnection);
         connect(this,
                 &TimelineModel::newMessageToSend,
                 this,
                 &TimelineModel::addPendingMessage,
                 Qt::QueuedConnection);
+        connect(this, &TimelineModel::addPendingMessageToStore, &events, &EventStore::addPending);
 
         connect(
+          &events,
+          &EventStore::dataChanged,
           this,
-          &TimelineModel::eventFetched,
-          this,
-          [this](QString requestingEvent, mtx::events::collections::TimelineEvents event) {
-                  events.insert(QString::fromStdString(mtx::accessors::event_id(event)), event);
-                  auto idx = idToIndex(requestingEvent);
-                  if (idx >= 0)
-                          emit dataChanged(index(idx, 0), index(idx, 0));
+          [this](int from, int to) {
+                  nhlog::ui()->debug(
+                    "data changed {} to {}", events.size() - to - 1, events.size() - from - 1);
+                  emit dataChanged(index(events.size() - to - 1, 0),
+                                   index(events.size() - from - 1, 0));
           },
           Qt::QueuedConnection);
+
+        connect(&events, &EventStore::beginInsertRows, this, [this](int from, int to) {
+                int first = events.size() - to;
+                int last  = events.size() - from;
+                if (from >= events.size()) {
+                        int batch_size = to - from;
+                        first += batch_size;
+                        last += batch_size;
+                } else {
+                        first -= 1;
+                        last -= 1;
+                }
+                nhlog::ui()->debug("begin insert from {} to {}", first, last);
+                beginInsertRows(QModelIndex(), first, last);
+        });
+        connect(&events, &EventStore::endInsertRows, this, [this]() { endInsertRows(); });
+        connect(&events, &EventStore::beginResetModel, this, [this]() { beginResetModel(); });
+        connect(&events, &EventStore::endResetModel, this, [this]() { endResetModel(); });
+        connect(&events, &EventStore::newEncryptedImage, this, &TimelineModel::newEncryptedImage);
+        connect(
+          &events, &EventStore::fetchedMore, this, [this]() { setPaginationInProgress(false); });
 }
 
 QHash
@@ -290,28 +252,22 @@ int
 TimelineModel::rowCount(const QModelIndex &parent) const
 {
         Q_UNUSED(parent);
-        return (int)this->eventOrder.size();
+        return this->events.size();
 }
 
 QVariantMap
-TimelineModel::getDump(QString eventId) const
+TimelineModel::getDump(QString eventId, QString relatedTo) const
 {
-        if (events.contains(eventId))
-                return data(eventId, Dump).toMap();
+        if (auto event = events.get(eventId.toStdString(), relatedTo.toStdString()))
+                return data(*event, Dump).toMap();
         return {};
 }
 
 QVariant
-TimelineModel::data(const QString &id, int role) const
+TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int role) const
 {
         using namespace mtx::accessors;
-        namespace acc                                  = mtx::accessors;
-        mtx::events::collections::TimelineEvents event = events.value(id);
-
-        if (auto e =
-              std::get_if>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        namespace acc = mtx::accessors;
 
         switch (role) {
         case UserId:
@@ -397,8 +353,9 @@ TimelineModel::data(const QString &id, int role) const
                 return QVariant(prop > 0 ? prop : 1.);
         }
         case Id:
-                return id;
+                return QVariant(QString::fromStdString(event_id(event)));
         case State: {
+                auto id             = QString::fromStdString(event_id(event));
                 auto containsOthers = [](const auto &vec) {
                         for (const auto &e : vec)
                                 if (e.second != http::client()->user_id().to_string())
@@ -409,7 +366,7 @@ TimelineModel::data(const QString &id, int role) const
                 // only show read receipts for messages not from us
                 if (acc::sender(event) != http::client()->user_id().to_string())
                         return qml_mtx_events::Empty;
-                else if (pending.contains(id))
+                else if (!id.isEmpty() && id[0] == "m")
                         return qml_mtx_events::Sent;
                 else if (read.contains(id) || containsOthers(cache::readReceipts(id, room_id_)))
                         return qml_mtx_events::Read;
@@ -417,19 +374,22 @@ TimelineModel::data(const QString &id, int role) const
                         return qml_mtx_events::Received;
         }
         case IsEncrypted: {
-                return std::holds_alternative<
-                  mtx::events::EncryptedEvent>(events[id]);
+                auto id              = event_id(event);
+                auto encrypted_event = events.get(id, id, false);
+                return encrypted_event &&
+                       std::holds_alternative<
+                         mtx::events::EncryptedEvent>(
+                         *encrypted_event);
         }
         case IsRoomEncrypted: {
                 return cache::isRoomEncrypted(room_id_.toStdString());
         }
         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 Reactions: {
+                auto id = event_id(event);
+                return QVariant::fromValue(events.reactions(id));
+        }
         case RoomId:
                 return QVariant(room_id_);
         case RoomName:
@@ -443,31 +403,32 @@ TimelineModel::data(const QString &id, int role) const
                 auto names = roleNames();
 
                 // m.insert(names[Section], data(id, static_cast(Section)));
-                m.insert(names[Type], data(id, static_cast(Type)));
-                m.insert(names[TypeString], data(id, static_cast(TypeString)));
-                m.insert(names[IsOnlyEmoji], data(id, static_cast(IsOnlyEmoji)));
-                m.insert(names[Body], data(id, static_cast(Body)));
-                m.insert(names[FormattedBody], data(id, static_cast(FormattedBody)));
-                m.insert(names[UserId], data(id, static_cast(UserId)));
-                m.insert(names[UserName], data(id, static_cast(UserName)));
-                m.insert(names[Timestamp], data(id, static_cast(Timestamp)));
-                m.insert(names[Url], data(id, static_cast(Url)));
-                m.insert(names[ThumbnailUrl], data(id, static_cast(ThumbnailUrl)));
-                m.insert(names[Blurhash], data(id, static_cast(Blurhash)));
-                m.insert(names[Filename], data(id, static_cast(Filename)));
-                m.insert(names[Filesize], data(id, static_cast(Filesize)));
-                m.insert(names[MimeType], data(id, static_cast(MimeType)));
-                m.insert(names[Height], data(id, static_cast(Height)));
-                m.insert(names[Width], data(id, static_cast(Width)));
-                m.insert(names[ProportionalHeight], data(id, static_cast(ProportionalHeight)));
-                m.insert(names[Id], data(id, static_cast(Id)));
-                m.insert(names[State], data(id, static_cast(State)));
-                m.insert(names[IsEncrypted], data(id, static_cast(IsEncrypted)));
-                m.insert(names[IsRoomEncrypted], data(id, static_cast(IsRoomEncrypted)));
-                m.insert(names[ReplyTo], data(id, static_cast(ReplyTo)));
-                m.insert(names[RoomName], data(id, static_cast(RoomName)));
-                m.insert(names[RoomTopic], data(id, static_cast(RoomTopic)));
-                m.insert(names[CallType], data(id, static_cast(CallType)));
+                m.insert(names[Type], data(event, static_cast(Type)));
+                m.insert(names[TypeString], data(event, static_cast(TypeString)));
+                m.insert(names[IsOnlyEmoji], data(event, static_cast(IsOnlyEmoji)));
+                m.insert(names[Body], data(event, static_cast(Body)));
+                m.insert(names[FormattedBody], data(event, static_cast(FormattedBody)));
+                m.insert(names[UserId], data(event, static_cast(UserId)));
+                m.insert(names[UserName], data(event, static_cast(UserName)));
+                m.insert(names[Timestamp], data(event, static_cast(Timestamp)));
+                m.insert(names[Url], data(event, static_cast(Url)));
+                m.insert(names[ThumbnailUrl], data(event, static_cast(ThumbnailUrl)));
+                m.insert(names[Blurhash], data(event, static_cast(Blurhash)));
+                m.insert(names[Filename], data(event, static_cast(Filename)));
+                m.insert(names[Filesize], data(event, static_cast(Filesize)));
+                m.insert(names[MimeType], data(event, static_cast(MimeType)));
+                m.insert(names[Height], data(event, static_cast(Height)));
+                m.insert(names[Width], data(event, static_cast(Width)));
+                m.insert(names[ProportionalHeight],
+                         data(event, static_cast(ProportionalHeight)));
+                m.insert(names[Id], data(event, static_cast(Id)));
+                m.insert(names[State], data(event, static_cast(State)));
+                m.insert(names[IsEncrypted], data(event, static_cast(IsEncrypted)));
+                m.insert(names[IsRoomEncrypted], data(event, static_cast(IsRoomEncrypted)));
+                m.insert(names[ReplyTo], data(event, static_cast(ReplyTo)));
+                m.insert(names[RoomName], data(event, static_cast(RoomName)));
+                m.insert(names[RoomTopic], data(event, static_cast(RoomTopic)));
+                m.insert(names[CallType], data(event, static_cast(CallType)));
 
                 return QVariant(m);
         }
@@ -481,29 +442,33 @@ TimelineModel::data(const QModelIndex &index, int role) const
 {
         using namespace mtx::accessors;
         namespace acc = mtx::accessors;
-        if (index.row() < 0 && index.row() >= (int)eventOrder.size())
+        if (index.row() < 0 && index.row() >= rowCount())
                 return QVariant();
 
-        QString id = eventOrder[index.row()];
+        auto event = events.get(rowCount() - index.row() - 1);
 
-        mtx::events::collections::TimelineEvents event = events.value(id);
+        if (!event)
+                return "";
 
         if (role == Section) {
-                QDateTime date = origin_server_ts(event);
+                QDateTime date = origin_server_ts(*event);
                 date.setTime(QTime());
 
-                std::string userId = acc::sender(event);
+                std::string userId = acc::sender(*event);
 
-                for (size_t r = index.row() + 1; r < eventOrder.size(); r++) {
-                        auto tempEv        = events.value(eventOrder[r]);
-                        QDateTime prevDate = origin_server_ts(tempEv);
+                for (int r = rowCount() - index.row(); r < events.size(); r++) {
+                        auto tempEv = events.get(r);
+                        if (!tempEv)
+                                break;
+
+                        QDateTime prevDate = origin_server_ts(*tempEv);
                         prevDate.setTime(QTime());
                         if (prevDate != date)
                                 return QString("%2 %1")
                                   .arg(date.toMSecsSinceEpoch())
                                   .arg(QString::fromStdString(userId));
 
-                        std::string prevUserId = acc::sender(tempEv);
+                        std::string prevUserId = acc::sender(*tempEv);
                         if (userId != prevUserId)
                                 break;
                 }
@@ -511,16 +476,17 @@ TimelineModel::data(const QModelIndex &index, int role) const
                 return QString("%1").arg(QString::fromStdString(userId));
         }
 
-        return data(id, role);
+        return data(*event, role);
 }
 
 bool
 TimelineModel::canFetchMore(const QModelIndex &) const
 {
-        if (eventOrder.empty())
+        if (!events.size())
                 return true;
-        if (!std::holds_alternative>(
-              events[eventOrder.back()]))
+        if (auto first = events.get(0);
+            first &&
+            !std::holds_alternative>(*first))
                 return true;
         else
 
@@ -547,50 +513,44 @@ TimelineModel::fetchMore(const QModelIndex &)
         }
 
         setPaginationInProgress(true);
-        mtx::http::MessagesOpts opts;
-        opts.room_id = room_id_.toStdString();
-        opts.from    = prev_batch_token_.toStdString();
 
-        nhlog::ui()->debug("Paginating room {}", opts.room_id);
-
-        http::client()->messages(
-          opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) {
-                  if (err) {
-                          nhlog::net()->error("failed to call /messages ({}): {} - {} - {}",
-                                              opts.room_id,
-                                              mtx::errors::to_string(err->matrix_error.errcode),
-                                              err->matrix_error.error,
-                                              err->parse_error);
-                          setPaginationInProgress(false);
-                          return;
-                  }
-
-                  emit oldMessagesRetrieved(std::move(res));
-                  setPaginationInProgress(false);
-          });
+        events.fetchMore();
 }
 
 void
 TimelineModel::addEvents(const mtx::responses::Timeline &timeline)
 {
-        if (isInitialSync) {
-                prev_batch_token_ = QString::fromStdString(timeline.prev_batch);
-                isInitialSync     = false;
-        }
-
         if (timeline.events.empty())
                 return;
 
-        std::vector ids = internalAddEvents(timeline.events, true);
+        events.handleSync(timeline);
 
-        if (!ids.empty()) {
-                beginInsertRows(QModelIndex(), 0, static_cast(ids.size() - 1));
-                this->eventOrder.insert(this->eventOrder.begin(), ids.rbegin(), ids.rend());
-                endInsertRows();
+        using namespace mtx::events;
+        for (auto e : timeline.events) {
+                if (auto encryptedEvent = std::get_if>(&e)) {
+                        MegolmSessionIndex index;
+                        index.room_id    = room_id_.toStdString();
+                        index.session_id = encryptedEvent->content.session_id;
+                        index.sender_key = encryptedEvent->content.sender_key;
+
+                        auto result = olm::decryptEvent(index, *encryptedEvent);
+                        if (result.event)
+                                e = result.event.value();
+                }
+
+                if (std::holds_alternative>(e) ||
+                    std::holds_alternative>(e) ||
+                    std::holds_alternative>(e) ||
+                    std::holds_alternative>(e))
+                        std::visit(
+                          [this](auto &event) {
+                                  event.room_id = room_id_.toStdString();
+                                  if (event.sender != http::client()->user_id().to_string())
+                                          emit newCallEvent(event);
+                          },
+                          e);
         }
-
-        if (!timeline.events.empty())
-                updateLastMessage();
+        updateLastMessage();
 }
 
 template
@@ -649,21 +609,17 @@ isYourJoin(const mtx::events::Event &)
 void
 TimelineModel::updateLastMessage()
 {
-        for (auto it = eventOrder.begin(); it != eventOrder.end(); ++it) {
-                auto event = events.value(*it);
-                if (auto e = std::get_if>(
-                      &event)) {
-                        if (decryptDescription) {
-                                event = decryptEvent(*e).event;
-                        }
-                }
+        for (auto it = events.size() - 1; it >= 0; --it) {
+                auto event = events.get(it, decryptDescription);
+                if (!event)
+                        continue;
 
-                if (std::visit([](const auto &e) -> bool { return isYourJoin(e); }, event)) {
-                        auto time   = mtx::accessors::origin_server_ts(event);
+                if (std::visit([](const auto &e) -> bool { return isYourJoin(e); }, *event)) {
+                        auto time   = mtx::accessors::origin_server_ts(*event);
                         uint64_t ts = time.toMSecsSinceEpoch();
                         emit manager_->updateRoomsLastMessage(
                           room_id_,
-                          DescInfo{QString::fromStdString(mtx::accessors::event_id(event)),
+                          DescInfo{QString::fromStdString(mtx::accessors::event_id(*event)),
                                    QString::fromStdString(http::client()->user_id().to_string()),
                                    tr("You joined this room."),
                                    utils::descriptiveTime(time),
@@ -671,191 +627,16 @@ TimelineModel::updateLastMessage()
                                    time});
                         return;
                 }
-                if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, event))
+                if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, *event))
                         continue;
 
                 auto description = utils::getMessageDescription(
-                  event, QString::fromStdString(http::client()->user_id().to_string()), room_id_);
+                  *event, QString::fromStdString(http::client()->user_id().to_string()), room_id_);
                 emit manager_->updateRoomsLastMessage(room_id_, description);
                 return;
         }
 }
 
-std::vector
-TimelineModel::internalAddEvents(
-  const std::vector &timeline,
-  bool emitCallEvents)
-{
-        std::vector ids;
-        for (auto e : timeline) {
-                QString id = QString::fromStdString(mtx::accessors::event_id(e));
-
-                if (this->events.contains(id)) {
-                        this->events.insert(id, e);
-                        int idx = idToIndex(id);
-                        emit dataChanged(index(idx, 0), index(idx, 0));
-                        continue;
-                }
-
-                QString txid = QString::fromStdString(mtx::accessors::transaction_id(e));
-                if (this->pending.removeOne(txid)) {
-                        this->events.insert(id, e);
-                        this->events.remove(txid);
-                        int idx = idToIndex(txid);
-                        if (idx < 0) {
-                                nhlog::ui()->warn("Received index out of range");
-                                continue;
-                        }
-                        eventOrder[idx] = id;
-                        emit dataChanged(index(idx, 0), index(idx, 0));
-                        continue;
-                }
-
-                if (auto redaction =
-                      std::get_if>(&e)) {
-                        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>(
-                                &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)
-                                    -> mtx::events::RoomEvent {
-                                          mtx::events::RoomEvent
-                                            replacement                = {};
-                                          replacement.event_id         = ev.event_id;
-                                          replacement.room_id          = ev.room_id;
-                                          replacement.sender           = ev.sender;
-                                          replacement.origin_server_ts = ev.origin_server_ts;
-                                          replacement.type             = ev.type;
-                                          return replacement;
-                                  },
-                                  e);
-                                events.insert(redacts, redactedEvent);
-
-                                int row = (int)std::distance(eventOrder.begin(), redacted);
-                                emit dataChanged(index(row, 0), index(row, 0));
-                        }
-
-                        continue; // don't insert redaction into timeline
-                }
-
-                if (auto reaction =
-                      std::get_if>(&e)) {
-                        QString reactedTo =
-                          QString::fromStdString(reaction->content.relates_to.event_id);
-                        events.insert(id, e);
-
-                        // remove local echo
-                        if (!txid.isEmpty()) {
-                                auto rCopy     = *reaction;
-                                rCopy.event_id = txid.toStdString();
-                                reactions[reactedTo].removeReaction(rCopy);
-                        }
-
-                        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>(&e)) {
-                        auto e_      = decryptEvent(*event).event;
-                        auto encInfo = mtx::accessors::file(e_);
-
-                        if (encInfo)
-                                emit newEncryptedImage(encInfo.value());
-
-                        if (std::holds_alternative<
-                              mtx::events::RoomEvent>(e_)) {
-                                // don't display CallCandidate events to user
-                                events.insert(id, e);
-                                if (emitCallEvents)
-                                        emit newCallEvent(e_);
-                                continue;
-                        }
-
-                        if (emitCallEvents) {
-                                if (auto callInvite = std::get_if<
-                                      mtx::events::RoomEvent>(&e_)) {
-                                        callInvite->room_id = room_id_.toStdString();
-                                        emit newCallEvent(e_);
-                                } else if (std::holds_alternative>(e_) ||
-                                           std::holds_alternative<
-                                             mtx::events::RoomEvent>(
-                                             e_) ||
-                                           std::holds_alternative<
-                                             mtx::events::RoomEvent>(
-                                             e_)) {
-                                        emit newCallEvent(e_);
-                                }
-                        }
-                }
-
-                if (std::holds_alternative<
-                      mtx::events::RoomEvent>(e)) {
-                        // don't display CallCandidate events to user
-                        events.insert(id, e);
-                        if (emitCallEvents)
-                                emit newCallEvent(e);
-                        continue;
-                }
-
-                if (emitCallEvents) {
-                        if (auto callInvite =
-                              std::get_if>(
-                                &e)) {
-                                callInvite->room_id = room_id_.toStdString();
-                                emit newCallEvent(e);
-                        } else if (std::holds_alternative<
-                                     mtx::events::RoomEvent>(e) ||
-                                   std::holds_alternative<
-                                     mtx::events::RoomEvent>(e)) {
-                                emit newCallEvent(e);
-                        }
-                }
-
-                this->events.insert(id, e);
-                ids.push_back(id);
-
-                auto replyTo  = mtx::accessors::in_reply_to_event(e);
-                auto qReplyTo = QString::fromStdString(replyTo);
-                if (!replyTo.empty() && !events.contains(qReplyTo)) {
-                        http::client()->get_event(
-                          this->room_id_.toStdString(),
-                          replyTo,
-                          [this, id, replyTo](
-                            const mtx::events::collections::TimelineEvents &timeline,
-                            mtx::http::RequestErr err) {
-                                  if (err) {
-                                          nhlog::net()->error(
-                                            "Failed to retrieve event with id {}, which was "
-                                            "requested to show the replyTo for event {}",
-                                            replyTo,
-                                            id.toStdString());
-                                          return;
-                                  }
-                                  emit eventFetched(id, timeline);
-                          });
-                }
-        }
-        return ids;
-}
-
 void
 TimelineModel::setCurrentIndex(int index)
 {
@@ -863,7 +644,7 @@ TimelineModel::setCurrentIndex(int index)
         currentId     = indexToId(index);
         emit currentIndexChanged(index);
 
-        if ((oldIndex > index || oldIndex == -1) && !pending.contains(currentId) &&
+        if ((oldIndex > index || oldIndex == -1) && !currentId.startsWith("m") &&
             ChatPage::instance()->isActiveWindow()) {
                 readEvent(currentId.toStdString());
         }
@@ -881,27 +662,6 @@ TimelineModel::readEvent(const std::string &id)
         });
 }
 
-void
-TimelineModel::addBackwardsEvents(const mtx::responses::Messages &msgs)
-{
-        std::vector ids = internalAddEvents(msgs.chunk, false);
-
-        if (!ids.empty()) {
-                beginInsertRows(QModelIndex(),
-                                static_cast(this->eventOrder.size()),
-                                static_cast(this->eventOrder.size() + ids.size() - 1));
-                this->eventOrder.insert(this->eventOrder.end(), ids.begin(), ids.end());
-                endInsertRows();
-        }
-
-        prev_batch_token_ = QString::fromStdString(msgs.end);
-
-        if (ids.empty() && !msgs.chunk.empty()) {
-                // no visible events fetched, prevent loading from stopping
-                fetchMore(QModelIndex());
-        }
-}
-
 QString
 TimelineModel::displayName(QString id) const
 {
@@ -938,7 +698,10 @@ TimelineModel::escapeEmoji(QString str) const
 void
 TimelineModel::viewRawMessage(QString id) const
 {
-        std::string ev = mtx::accessors::serialize_event(events.value(id)).dump(4);
+        auto e = events.get(id.toStdString(), "", false);
+        if (!e)
+                return;
+        std::string ev = mtx::accessors::serialize_event(*e).dump(4);
         auto dialog    = new dialogs::RawMessage(QString::fromStdString(ev));
         Q_UNUSED(dialog);
 }
@@ -946,13 +709,11 @@ TimelineModel::viewRawMessage(QString id) const
 void
 TimelineModel::viewDecryptedRawMessage(QString id) const
 {
-        auto event = events.value(id);
-        if (auto e =
-              std::get_if>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        auto e = events.get(id.toStdString(), "");
+        if (!e)
+                return;
 
-        std::string ev = mtx::accessors::serialize_event(event).dump(4);
+        std::string ev = mtx::accessors::serialize_event(*e).dump(4);
         auto dialog    = new dialogs::RawMessage(QString::fromStdString(ev));
         Q_UNUSED(dialog);
 }
@@ -963,114 +724,6 @@ TimelineModel::openUserProfile(QString userid) const
         MainWindow::instance()->openUserProfile(userid, room_id_);
 }
 
-DecryptionResult
-TimelineModel::decryptEvent(const mtx::events::EncryptedEvent &e) const
-{
-        static QCache decryptedEvents{300};
-
-        if (auto cachedEvent = decryptedEvents.object(e.event_id))
-                return *cachedEvent;
-
-        MegolmSessionIndex index;
-        index.room_id    = room_id_.toStdString();
-        index.session_id = e.content.session_id;
-        index.sender_key = e.content.sender_key;
-
-        mtx::events::RoomEvent dummy;
-        dummy.origin_server_ts = e.origin_server_ts;
-        dummy.event_id         = e.event_id;
-        dummy.sender           = e.sender;
-        dummy.content.body =
-          tr("-- Encrypted Event (No keys found for decryption) --",
-             "Placeholder, when the message was not decrypted yet or can't be decrypted.")
-            .toStdString();
-
-        try {
-                if (!cache::inboundMegolmSessionExists(index)) {
-                        nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})",
-                                              index.room_id,
-                                              index.session_id,
-                                              e.sender);
-                        // TODO: request megolm session_id & session_key from the sender.
-                        decryptedEvents.insert(
-                          dummy.event_id, new DecryptionResult{dummy, false}, 1);
-                        return {dummy, false};
-                }
-        } catch (const lmdb::error &e) {
-                nhlog::db()->critical("failed to check megolm session's existence: {}", e.what());
-                dummy.content.body = tr("-- Decryption Error (failed to communicate with DB) --",
-                                        "Placeholder, when the message can't be decrypted, because "
-                                        "the DB access failed when trying to lookup the session.")
-                                       .toStdString();
-                decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1);
-                return {dummy, false};
-        }
-
-        std::string msg_str;
-        try {
-                auto session = cache::getInboundMegolmSession(index);
-                auto res     = olm::client()->decrypt_group_message(session, e.content.ciphertext);
-                msg_str      = std::string((char *)res.data.data(), res.data.size());
-        } catch (const lmdb::error &e) {
-                nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})",
-                                      index.room_id,
-                                      index.session_id,
-                                      index.sender_key,
-                                      e.what());
-                dummy.content.body =
-                  tr("-- Decryption Error (failed to retrieve megolm keys from db) --",
-                     "Placeholder, when the message can't be decrypted, because the DB access "
-                     "failed.")
-                    .toStdString();
-                decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1);
-                return {dummy, false};
-        } catch (const mtx::crypto::olm_exception &e) {
-                nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}",
-                                          index.room_id,
-                                          index.session_id,
-                                          index.sender_key,
-                                          e.what());
-                dummy.content.body =
-                  tr("-- Decryption Error (%1) --",
-                     "Placeholder, when the message can't be decrypted. In this case, the Olm "
-                     "decrytion returned an error, which is passed ad %1.")
-                    .arg(e.what())
-                    .toStdString();
-                decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1);
-                return {dummy, false};
-        }
-
-        // Add missing fields for the event.
-        json body                = json::parse(msg_str);
-        body["event_id"]         = e.event_id;
-        body["sender"]           = e.sender;
-        body["origin_server_ts"] = e.origin_server_ts;
-        body["unsigned"]         = e.unsigned_data;
-
-        // relations are unencrypted in content...
-        if (json old_ev = e; old_ev["content"].count("m.relates_to") != 0)
-                body["content"]["m.relates_to"] = old_ev["content"]["m.relates_to"];
-
-        json event_array = json::array();
-        event_array.push_back(body);
-
-        std::vector temp_events;
-        mtx::responses::utils::parse_timeline_events(event_array, temp_events);
-
-        if (temp_events.size() == 1) {
-                decryptedEvents.insert(e.event_id, new DecryptionResult{temp_events[0], true}, 1);
-                return {temp_events[0], true};
-        }
-
-        dummy.content.body =
-          tr("-- Encrypted Event (Unknown event type) --",
-             "Placeholder, when the message was decrypted, but we couldn't parse it, because "
-             "Nheko/mtxclient don't support that event type yet.")
-            .toStdString();
-        decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1);
-        return {dummy, false};
-}
-
 void
 TimelineModel::replyAction(QString id)
 {
@@ -1081,23 +734,18 @@ TimelineModel::replyAction(QString id)
 RelatedInfo
 TimelineModel::relatedInfo(QString id)
 {
-        if (!events.contains(id))
+        auto event = events.get(id.toStdString(), "");
+        if (!event)
                 return {};
 
-        auto event = events.value(id);
-        if (auto e =
-              std::get_if>(&event)) {
-                event = decryptEvent(*e).event;
-        }
-
         RelatedInfo related   = {};
-        related.quoted_user   = QString::fromStdString(mtx::accessors::sender(event));
-        related.related_event = mtx::accessors::event_id(event);
-        related.type          = mtx::accessors::msg_type(event);
+        related.quoted_user   = QString::fromStdString(mtx::accessors::sender(*event));
+        related.related_event = mtx::accessors::event_id(*event);
+        related.type          = mtx::accessors::msg_type(*event);
 
         // get body, strip reply fallback, then transform the event to text, if it is a media event
         // etc
-        related.quoted_body = QString::fromStdString(mtx::accessors::body(event));
+        related.quoted_body = QString::fromStdString(mtx::accessors::body(*event));
         QRegularExpression plainQuote("^>.*?$\n?", QRegularExpression::MultilineOption);
         while (related.quoted_body.startsWith(">"))
                 related.quoted_body.remove(plainQuote);
@@ -1106,7 +754,7 @@ TimelineModel::relatedInfo(QString id)
         related.quoted_body = utils::getQuoteBody(related);
 
         // get quoted body and strip reply fallback
-        related.quoted_formatted_body = mtx::accessors::formattedBodyWithFallback(event);
+        related.quoted_formatted_body = mtx::accessors::formattedBodyWithFallback(*event);
         related.quoted_formatted_body.remove(QRegularExpression(
           ".*", QRegularExpression::DotMatchesEverythingOption));
         related.room = room_id_;
@@ -1144,18 +792,19 @@ TimelineModel::idToIndex(QString id) const
 {
         if (id.isEmpty())
                 return -1;
-        for (int i = 0; i < (int)eventOrder.size(); i++)
-                if (id == eventOrder[i])
-                        return i;
-        return -1;
+
+        auto idx = events.idToIndex(id.toStdString());
+        if (idx)
+                return events.size() - *idx - 1;
+        else
+                return -1;
 }
 
 QString
 TimelineModel::indexToId(int index) const
 {
-        if (index < 0 || index >= (int)eventOrder.size())
-                return "";
-        return eventOrder[index];
+        auto id = events.indexToId(events.size() - index - 1);
+        return id ? QString::fromStdString(*id) : "";
 }
 
 // Note: this will only be called for our messages
@@ -1189,28 +838,16 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id,
         try {
                 // Check if we have already an outbound megolm session then we can use.
                 if (cache::outboundMegolmSessionExists(room_id)) {
-                        auto data =
+                        mtx::events::EncryptedEvent event;
+                        event.content =
                           olm::encrypt_group_message(room_id, http::client()->device_id(), doc);
+                        event.event_id         = txn_id;
+                        event.room_id          = room_id;
+                        event.sender           = http::client()->user_id().to_string();
+                        event.type             = mtx::events::EventType::RoomEncrypted;
+                        event.origin_server_ts = QDateTime::currentMSecsSinceEpoch();
 
-                        http::client()->send_room_message(
-                          room_id,
-                          txn_id,
-                          data,
-                          [this, txn_id](const mtx::responses::EventId &res,
-                                         mtx::http::RequestErr err) {
-                                  if (err) {
-                                          const int status_code =
-                                            static_cast(err->status_code);
-                                          nhlog::net()->warn("[{}] failed to send message: {} {}",
-                                                             txn_id,
-                                                             err->matrix_error.error,
-                                                             status_code);
-                                          emit messageFailed(QString::fromStdString(txn_id));
-                                  }
-                                  emit messageSent(
-                                    QString::fromStdString(txn_id),
-                                    QString::fromStdString(res.event_id.to_string()));
-                          });
+                        emit this->addPendingMessageToStore(event);
                         return;
                 }
 
@@ -1239,40 +876,25 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id,
                 const auto members = cache::roomMembers(room_id);
                 nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id);
 
-                auto keeper =
-                  std::make_shared([megolm_payload, room_id, doc, txn_id, this]() {
-                          try {
-                                  auto data = olm::encrypt_group_message(
-                                    room_id, http::client()->device_id(), doc);
+                auto keeper = std::make_shared([room_id, doc, txn_id, this]() {
+                        try {
+                                mtx::events::EncryptedEvent event;
+                                event.content = olm::encrypt_group_message(
+                                  room_id, http::client()->device_id(), doc);
+                                event.event_id         = txn_id;
+                                event.room_id          = room_id;
+                                event.sender           = http::client()->user_id().to_string();
+                                event.type             = mtx::events::EventType::RoomEncrypted;
+                                event.origin_server_ts = QDateTime::currentMSecsSinceEpoch();
 
-                                  http::client()
-                                    ->send_room_message(
-                                      room_id,
-                                      txn_id,
-                                      data,
-                                      [this, txn_id](const mtx::responses::EventId &res,
-                                                     mtx::http::RequestErr err) {
-                                              if (err) {
-                                                      const int status_code =
-                                                        static_cast(err->status_code);
-                                                      nhlog::net()->warn(
-                                                        "[{}] failed to send message: {} {}",
-                                                        txn_id,
-                                                        err->matrix_error.error,
-                                                        status_code);
-                                                      emit messageFailed(
-                                                        QString::fromStdString(txn_id));
-                                              }
-                                              emit messageSent(
-                                                QString::fromStdString(txn_id),
-                                                QString::fromStdString(res.event_id.to_string()));
-                                      });
-                          } catch (const lmdb::error &e) {
-                                  nhlog::db()->critical(
-                                    "failed to save megolm outbound session: {}", e.what());
-                                  emit messageFailed(QString::fromStdString(txn_id));
-                          }
-                  });
+                                emit this->addPendingMessageToStore(event);
+                        } catch (const lmdb::error &e) {
+                                nhlog::db()->critical("failed to save megolm outbound session: {}",
+                                                      e.what());
+                                emit ChatPage::instance()->showNotification(
+                                  tr("Failed to encrypt event, sending aborted!"));
+                        }
+                });
 
                 mtx::requests::QueryKeys req;
                 for (const auto &member : members)
@@ -1286,8 +908,8 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id,
                                   nhlog::net()->warn("failed to query device keys: {} {}",
                                                      err->matrix_error.error,
                                                      static_cast(err->status_code));
-                                  // TODO: Mark the event as failed. Communicate with the UI.
-                                  emit messageFailed(QString::fromStdString(txn_id));
+                                  emit ChatPage::instance()->showNotification(
+                                    tr("Failed to encrypt event, sending aborted!"));
                                   return;
                           }
 
@@ -1387,11 +1009,13 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id,
         } catch (const lmdb::error &e) {
                 nhlog::db()->critical(
                   "failed to open outbound megolm session ({}): {}", room_id, e.what());
-                emit messageFailed(QString::fromStdString(txn_id));
+                emit ChatPage::instance()->showNotification(
+                  tr("Failed to encrypt event, sending aborted!"));
         } catch (const mtx::crypto::olm_exception &e) {
                 nhlog::crypto()->critical(
                   "failed to open outbound megolm session ({}): {}", room_id, e.what());
-                emit messageFailed(QString::fromStdString(txn_id));
+                emit ChatPage::instance()->showNotification(
+                  tr("Failed to encrypt event, sending aborted!"));
         }
 }
 
@@ -1483,13 +1107,12 @@ TimelineModel::handleClaimedKeys(std::shared_ptr keeper,
 
 struct SendMessageVisitor
 {
-        SendMessageVisitor(const QString &txn_id, TimelineModel *model)
-          : txn_id_qstr_(txn_id)
-          , model_(model)
+        explicit SendMessageVisitor(TimelineModel *model)
+          : model_(model)
         {}
 
         template
-        void sendRoomEvent(const mtx::events::RoomEvent &msg)
+        void sendRoomEvent(mtx::events::RoomEvent msg)
         {
                 if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
                         auto encInfo = mtx::accessors::file(msg);
@@ -1497,36 +1120,13 @@ struct SendMessageVisitor
                                 emit model_->newEncryptedImage(encInfo.value());
 
                         model_->sendEncryptedMessageEvent(
-                          txn_id_qstr_.toStdString(), nlohmann::json(msg.content), Event);
+                          msg.event_id, nlohmann::json(msg.content), Event);
                 } else {
-                        sendUnencryptedRoomEvent(msg);
+                        msg.type = Event;
+                        emit model_->addPendingMessageToStore(msg);
                 }
         }
 
-        template
-        void sendUnencryptedRoomEvent(const mtx::events::RoomEvent &msg)
-        {
-                QString txn_id_qstr  = txn_id_qstr_;
-                TimelineModel *model = model_;
-                http::client()->send_room_message(
-                  model->room_id_.toStdString(),
-                  txn_id_qstr.toStdString(),
-                  msg.content,
-                  [txn_id_qstr, model](const mtx::responses::EventId &res,
-                                       mtx::http::RequestErr err) {
-                          if (err) {
-                                  const int status_code = static_cast(err->status_code);
-                                  nhlog::net()->warn("[{}] failed to send message: {} {}",
-                                                     txn_id_qstr.toStdString(),
-                                                     err->matrix_error.error,
-                                                     status_code);
-                                  emit model->messageFailed(txn_id_qstr);
-                          }
-                          emit model->messageSent(txn_id_qstr,
-                                                  QString::fromStdString(res.event_id.to_string()));
-                  });
-        }
-
         // Do-nothing operator for all unhandled events
         template
         void operator()(const mtx::events::Event &)
@@ -1535,7 +1135,7 @@ struct SendMessageVisitor
         // Operator for m.room.message events that contain a msgtype in their content
         template::value, int> = 0>
-        void operator()(const mtx::events::RoomEvent &msg)
+        void operator()(mtx::events::RoomEvent msg)
         {
                 sendRoomEvent(msg);
         }
@@ -1545,10 +1145,10 @@ struct SendMessageVisitor
         // reactions need to have the relation outside of ciphertext, or synapse / the homeserver
         // cannot handle it correctly.  See the MSC for more details:
         // https://github.com/matrix-org/matrix-doc/blob/matthew/msc1849/proposals/1849-aggregations.md#end-to-end-encryption
-        void operator()(const mtx::events::RoomEvent &msg)
+        void operator()(mtx::events::RoomEvent msg)
         {
-                sendUnencryptedRoomEvent(msg);
+                msg.type = mtx::events::EventType::Reaction;
+                emit model_->addPendingMessageToStore(msg);
         }
 
         void operator()(const mtx::events::RoomEvent &event)
@@ -1575,65 +1175,38 @@ struct SendMessageVisitor
                   event);
         }
 
-        QString txn_id_qstr_;
         TimelineModel *model_;
 };
 
-void
-TimelineModel::processOnePendingMessage()
-{
-        if (pending.isEmpty())
-                return;
-
-        QString txn_id_qstr = pending.first();
-
-        auto event = events.value(txn_id_qstr);
-        std::visit(SendMessageVisitor{txn_id_qstr, this}, event);
-}
-
 void
 TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event)
 {
         std::visit(
           [](auto &msg) {
-                  msg.event_id         = http::client()->generate_txn_id();
+                  msg.type             = mtx::events::EventType::RoomMessage;
+                  msg.event_id         = "m" + http::client()->generate_txn_id();
                   msg.sender           = http::client()->user_id().to_string();
                   msg.origin_server_ts = QDateTime::currentMSecsSinceEpoch();
           },
           event);
 
-        internalAddEvents({event}, false);
-
-        QString txn_id_qstr = QString::fromStdString(mtx::accessors::event_id(event));
-        pending.push_back(txn_id_qstr);
-        if (!std::get_if>(&event) &&
-            !std::get_if>(&event)) {
-                beginInsertRows(QModelIndex(), 0, 0);
-                this->eventOrder.insert(this->eventOrder.begin(), txn_id_qstr);
-                endInsertRows();
-        }
-        updateLastMessage();
-
-        emit nextPendingMessage();
+        std::visit(SendMessageVisitor{this}, event);
 }
 
 bool
 TimelineModel::saveMedia(QString eventId) const
 {
-        mtx::events::collections::TimelineEvents event = events.value(eventId);
+        mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
+        if (!event)
+                return false;
 
-        if (auto e =
-              std::get_if>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        QString mxcUrl           = QString::fromStdString(mtx::accessors::url(*event));
+        QString originalFilename = QString::fromStdString(mtx::accessors::filename(*event));
+        QString mimeType         = QString::fromStdString(mtx::accessors::mimetype(*event));
 
-        QString mxcUrl           = QString::fromStdString(mtx::accessors::url(event));
-        QString originalFilename = QString::fromStdString(mtx::accessors::filename(event));
-        QString mimeType         = QString::fromStdString(mtx::accessors::mimetype(event));
+        auto encryptionInfo = mtx::accessors::file(*event);
 
-        auto encryptionInfo = mtx::accessors::file(event);
-
-        qml_mtx_events::EventType eventType = toRoomEventType(event);
+        qml_mtx_events::EventType eventType = toRoomEventType(*event);
 
         QString dialogTitle;
         if (eventType == qml_mtx_events::EventType::ImageMessage) {
@@ -1698,18 +1271,15 @@ TimelineModel::saveMedia(QString eventId) const
 void
 TimelineModel::cacheMedia(QString eventId)
 {
-        mtx::events::collections::TimelineEvents event = events.value(eventId);
+        mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
+        if (!event)
+                return;
 
-        if (auto e =
-              std::get_if>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        QString mxcUrl           = QString::fromStdString(mtx::accessors::url(*event));
+        QString originalFilename = QString::fromStdString(mtx::accessors::filename(*event));
+        QString mimeType         = QString::fromStdString(mtx::accessors::mimetype(*event));
 
-        QString mxcUrl           = QString::fromStdString(mtx::accessors::url(event));
-        QString originalFilename = QString::fromStdString(mtx::accessors::filename(event));
-        QString mimeType         = QString::fromStdString(mtx::accessors::mimetype(event));
-
-        auto encryptionInfo = mtx::accessors::file(event);
+        auto encryptionInfo = mtx::accessors::file(*event);
 
         // If the message is a link to a non mxcUrl, don't download it
         if (!mxcUrl.startsWith("mxc://")) {
@@ -1830,11 +1400,11 @@ TimelineModel::formatTypingUsers(const std::vector &users, QColor bg)
 QString
 TimelineModel::formatJoinRuleEvent(QString id)
 {
-        if (!events.contains(id))
+        mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+        if (!e)
                 return "";
 
-        auto event =
-          std::get_if>(&events[id]);
+        auto event = std::get_if>(e);
         if (!event)
                 return "";
 
@@ -1855,11 +1425,11 @@ TimelineModel::formatJoinRuleEvent(QString id)
 QString
 TimelineModel::formatGuestAccessEvent(QString id)
 {
-        if (!events.contains(id))
+        mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+        if (!e)
                 return "";
 
-        auto event =
-          std::get_if>(&events[id]);
+        auto event = std::get_if>(e);
         if (!event)
                 return "";
 
@@ -1879,11 +1449,11 @@ TimelineModel::formatGuestAccessEvent(QString id)
 QString
 TimelineModel::formatHistoryVisibilityEvent(QString id)
 {
-        if (!events.contains(id))
+        mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+        if (!e)
                 return "";
 
-        auto event =
-          std::get_if>(&events[id]);
+        auto event = std::get_if>(e);
 
         if (!event)
                 return "";
@@ -1913,11 +1483,11 @@ TimelineModel::formatHistoryVisibilityEvent(QString id)
 QString
 TimelineModel::formatPowerLevelEvent(QString id)
 {
-        if (!events.contains(id))
+        mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+        if (!e)
                 return "";
 
-        auto event =
-          std::get_if>(&events[id]);
+        auto event = std::get_if>(e);
         if (!event)
                 return "";
 
@@ -1931,37 +1501,22 @@ TimelineModel::formatPowerLevelEvent(QString id)
 QString
 TimelineModel::formatMemberEvent(QString id)
 {
-        if (!events.contains(id))
+        mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+        if (!e)
                 return "";
 
-        auto event = std::get_if>(&events[id]);
+        auto event = std::get_if>(e);
         if (!event)
                 return "";
 
         mtx::events::StateEvent *prevEvent = nullptr;
-        QString prevEventId = QString::fromStdString(event->unsigned_data.replaces_state);
-        if (!prevEventId.isEmpty()) {
-                if (!events.contains(prevEventId)) {
-                        http::client()->get_event(
-                          this->room_id_.toStdString(),
-                          event->unsigned_data.replaces_state,
-                          [this, id, prevEventId](
-                            const mtx::events::collections::TimelineEvents &timeline,
-                            mtx::http::RequestErr err) {
-                                  if (err) {
-                                          nhlog::net()->error(
-                                            "Failed to retrieve event with id {}, which was "
-                                            "requested to show the membership for event {}",
-                                            prevEventId.toStdString(),
-                                            id.toStdString());
-                                          return;
-                                  }
-                                  emit eventFetched(id, timeline);
-                          });
-                } else {
+        if (!event->unsigned_data.replaces_state.empty()) {
+                auto tempPrevEvent =
+                  events.get(event->unsigned_data.replaces_state, event->event_id);
+                if (tempPrevEvent) {
                         prevEvent =
                           std::get_if>(
-                            &events[prevEventId]);
+                            tempPrevEvent);
                 }
         }
 
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index 95584d36..156606e6 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -9,7 +9,7 @@
 #include 
 
 #include "CacheCryptoStructs.h"
-#include "ReactionsModel.h"
+#include "EventStore.h"
 
 namespace mtx::http {
 using RequestErr = const std::optional &;
@@ -42,6 +42,8 @@ enum EventType
         CallAnswer,
         /// m.call.hangup
         CallHangUp,
+        /// m.call.candidates
+        CallCandidates,
         /// m.room.canonical_alias
         CanonicalAlias,
         /// m.room.create
@@ -177,7 +179,7 @@ public:
         QHash roleNames() const override;
         int rowCount(const QModelIndex &parent = QModelIndex()) const override;
         QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
-        QVariant data(const QString &id, int role) const;
+        QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const;
 
         bool canFetchMore(const QModelIndex &) const override;
         void fetchMore(const QModelIndex &) override;
@@ -204,6 +206,15 @@ public:
         Q_INVOKABLE void cacheMedia(QString eventId);
         Q_INVOKABLE bool saveMedia(QString eventId) const;
 
+        std::vector<::Reaction> reactions(const std::string &event_id)
+        {
+                auto list = events.reactions(event_id);
+                std::vector<::Reaction> vec;
+                for (const auto &r : list)
+                        vec.push_back(r.value());
+                return vec;
+        }
+
         void updateLastMessage();
         void addEvents(const mtx::responses::Timeline &events);
         template
@@ -214,7 +225,7 @@ public slots:
         void setCurrentIndex(int index);
         int currentIndex() const { return idToIndex(currentId); }
         void markEventsAsRead(const std::vector &event_ids);
-        QVariantMap getDump(QString eventId) const;
+        QVariantMap getDump(QString eventId, QString relatedTo) const;
         void updateTypingUsers(const std::vector &users)
         {
                 if (this->typingUsers_ != users) {
@@ -240,36 +251,26 @@ public slots:
                 }
         }
         void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
+        void clearTimeline() { events.clearTimeline(); }
 
 private slots:
-        // Add old events at the top of the timeline.
-        void addBackwardsEvents(const mtx::responses::Messages &msgs);
-        void processOnePendingMessage();
         void addPendingMessage(mtx::events::collections::TimelineEvents event);
 
 signals:
-        void oldMessagesRetrieved(const mtx::responses::Messages &res);
-        void messageFailed(QString txn_id);
-        void messageSent(QString txn_id, QString event_id);
         void currentIndexChanged(int index);
         void redactionFailed(QString id);
         void eventRedacted(QString id);
-        void nextPendingMessage();
-        void newMessageToSend(mtx::events::collections::TimelineEvents event);
         void mediaCached(QString mxcUrl, QString cacheUrl);
         void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
-        void eventFetched(QString requestingEvent, mtx::events::collections::TimelineEvents event);
         void typingUsersChanged(std::vector users);
         void replyChanged(QString reply);
         void paginationInProgressChanged(const bool);
         void newCallEvent(const mtx::events::collections::TimelineEvents &event);
 
+        void newMessageToSend(mtx::events::collections::TimelineEvents event);
+        void addPendingMessageToStore(mtx::events::collections::TimelineEvents event);
+
 private:
-        DecryptionResult decryptEvent(
-          const mtx::events::EncryptedEvent &e) const;
-        std::vector internalAddEvents(
-          const std::vector &timeline,
-          bool emitCallEvents);
         void sendEncryptedMessageEvent(const std::string &txn_id,
                                        nlohmann::json content,
                                        mtx::events::EventType);
@@ -283,16 +284,12 @@ private:
 
         void setPaginationInProgress(const bool paginationInProgress);
 
-        QHash events;
         QSet read;
-        QList pending;
-        std::vector eventOrder;
-        std::map reactions;
+
+        mutable EventStore events;
 
         QString room_id_;
-        QString prev_batch_token_;
 
-        bool isInitialSync          = true;
         bool decryptDescription     = true;
         bool m_paginationInProgress = false;
 
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index 5d0b54c3..466c3cee 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -340,35 +340,38 @@ TimelineViewManager::queueEmoteMessage(const QString &msg)
 }
 
 void
-TimelineViewManager::reactToMessage(const QString &roomId,
-                                    const QString &reactedEvent,
-                                    const QString &reactionKey,
-                                    const QString &selfReactedEvent)
+TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
 {
+        if (!timeline_)
+                return;
+
+        auto reactions = timeline_->reactions(reactedEvent.toStdString());
+
+        QString selfReactedEvent;
+        for (const auto &reaction : reactions) {
+                if (reactionKey == reaction.key_) {
+                        selfReactedEvent = reaction.selfReactedEvent_;
+                        break;
+                }
+        }
+
+        if (selfReactedEvent.startsWith("m"))
+                return;
+
         // If selfReactedEvent is empty, that means we haven't previously reacted
         if (selfReactedEvent.isEmpty()) {
-                queueReactionMessage(roomId, reactedEvent, reactionKey);
+                mtx::events::msg::Reaction reaction;
+                reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
+                reaction.relates_to.event_id = reactedEvent.toStdString();
+                reaction.relates_to.key      = reactionKey.toStdString();
+
+                timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction);
                 // Otherwise, we have previously reacted and the reaction should be redacted
         } else {
-                auto model = models.value(roomId);
-                model->redactEvent(selfReactedEvent);
+                timeline_->redactEvent(selfReactedEvent);
         }
 }
 
-void
-TimelineViewManager::queueReactionMessage(const QString &roomId,
-                                          const QString &reactedEvent,
-                                          const QString &reactionKey)
-{
-        mtx::events::msg::Reaction reaction;
-        reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
-        reaction.relates_to.event_id = reactedEvent.toStdString();
-        reaction.relates_to.key      = reactionKey.toStdString();
-
-        auto model = models.value(roomId);
-        model->sendMessageEvent(reaction, mtx::events::EventType::RoomMessage);
-}
-
 void
 TimelineViewManager::queueImageMessage(const QString &roomid,
                                        const QString &filename,
@@ -384,10 +387,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid,
         image.info.size     = dsize;
         image.info.blurhash = blurhash.toStdString();
         image.body          = filename.toStdString();
-        image.url           = url.toStdString();
         image.info.h        = dimensions.height();
         image.info.w        = dimensions.width();
-        image.file          = file;
+
+        if (file)
+                image.file = file;
+        else
+                image.url = url.toStdString();
 
         auto model = models.value(roomid);
         if (!model->reply().isEmpty()) {
@@ -411,8 +417,11 @@ TimelineViewManager::queueFileMessage(
         file.info.mimetype = mime.toStdString();
         file.info.size     = dsize;
         file.body          = filename.toStdString();
-        file.url           = url.toStdString();
-        file.file          = encryptedFile;
+
+        if (encryptedFile)
+                file.file = encryptedFile;
+        else
+                file.url = url.toStdString();
 
         auto model = models.value(roomid);
         if (!model->reply().isEmpty()) {
@@ -436,7 +445,11 @@ TimelineViewManager::queueAudioMessage(const QString &roomid,
         audio.info.size     = dsize;
         audio.body          = filename.toStdString();
         audio.url           = url.toStdString();
-        audio.file          = file;
+
+        if (file)
+                audio.file = file;
+        else
+                audio.url = url.toStdString();
 
         auto model = models.value(roomid);
         if (!model->reply().isEmpty()) {
@@ -459,8 +472,11 @@ TimelineViewManager::queueVideoMessage(const QString &roomid,
         video.info.mimetype = mime.toStdString();
         video.info.size     = dsize;
         video.body          = filename.toStdString();
-        video.url           = url.toStdString();
-        video.file          = file;
+
+        if (file)
+                video.file = file;
+        else
+                video.url = url.toStdString();
 
         auto model = models.value(roomid);
         if (!model->reply().isEmpty()) {
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index 902dc047..ea6d1743 100644
--- a/src/timeline/TimelineViewManager.h
+++ b/src/timeline/TimelineViewManager.h
@@ -66,13 +66,7 @@ public slots:
 
         void setHistoryView(const QString &room_id);
         void updateColorPalette();
-        void queueReactionMessage(const QString &roomId,
-                                  const QString &reactedEvent,
-                                  const QString &reaction);
-        void reactToMessage(const QString &roomId,
-                            const QString &reactedEvent,
-                            const QString &reactionKey,
-                            const QString &selfReactedEvent);
+        void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
         void queueTextMessage(const QString &msg);
         void queueEmoteMessage(const QString &msg);
         void queueImageMessage(const QString &roomid,
@@ -108,6 +102,12 @@ public slots:
 
         void updateEncryptedDescriptions();
 
+        void clearCurrentRoomTimeline()
+        {
+                if (timeline_)
+                        timeline_->clearTimeline();
+        }
+
 private:
 #ifdef USE_QUICK_VIEW
         QQuickView *view;