summary refs log tree commit diff
path: root/src/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'src/timeline')
-rw-r--r--src/timeline/EventStore.cpp570
-rw-r--r--src/timeline/EventStore.h122
-rw-r--r--src/timeline/Reaction.cpp1
-rw-r--r--src/timeline/Reaction.h24
-rw-r--r--src/timeline/ReactionsModel.cpp98
-rw-r--r--src/timeline/ReactionsModel.h41
-rw-r--r--src/timeline/TimelineModel.cpp923
-rw-r--r--src/timeline/TimelineModel.h43
-rw-r--r--src/timeline/TimelineViewManager.cpp72
-rw-r--r--src/timeline/TimelineViewManager.h14
10 files changed, 1027 insertions, 881 deletions
diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
new file mode 100644
index 00000000..fca1d31d
--- /dev/null
+++ b/src/timeline/EventStore.cpp
@@ -0,0 +1,570 @@
+#include "EventStore.h"
+
+#include <QThread>
+#include <QTimer>
+
+#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::IdIndex, mtx::events::collections::TimelineEvents> EventStore::decryptedEvents_{
+  1000};
+QCache<EventStore::IdIndex, mtx::events::collections::TimelineEvents> EventStore::events_by_id_{
+  1000};
+QCache<EventStore::Index, mtx::events::collections::TimelineEvents> EventStore::events_{1000};
+
+EventStore::EventStore(std::string room_id, QObject *)
+  : room_id_(std::move(room_id))
+{
+        static auto reactionType = qRegisterMetaType<Reaction>();
+        (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)
+                          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<decltype(e.content)> !=
+                                        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<int>(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<uint64_t>::max();
+                this->last  = std::numeric_limits<uint64_t>::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<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(
+                        &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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
+                               &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<std::string> users;
+                std::string reactedBySelf;
+        };
+        std::map<std::string, TempReaction> aggregation;
+        std::vector<Reaction> 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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
+                      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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
+                        event_ptr))
+                        return decryptEvent({room_id_, encrypted->event_id}, *encrypted);
+
+        return event_ptr;
+}
+
+std::optional<int>
+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<std::string>
+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<mtx::events::msg::Encrypted> &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<mtx::events::msg::Notice> 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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
+                        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 <limits>
+#include <string>
+
+#include <QCache>
+#include <QObject>
+#include <QVariant>
+#include <qhashfunctions.h>
+
+#include <mtx/events/collections.hpp>
+#include <mtx/responses/messages.hpp>
+#include <mtx/responses/sync.hpp>
+
+#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<uint64_t>::max()
+                         ? static_cast<int>(last - first) + 1
+                         : 0;
+        }
+        int toExternalIdx(uint64_t idx) const { return static_cast<int>(idx - first); }
+        uint64_t toInternalIdx(int idx) const { return first + idx; }
+
+        std::optional<int> idToIndex(std::string_view id) const;
+        std::optional<std::string> 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<mtx::events::msg::Encrypted> &e);
+
+        std::string room_id_;
+
+        uint64_t first = std::numeric_limits<uint64_t>::max(),
+                 last  = std::numeric_limits<uint64_t>::max();
+
+        static QCache<IdIndex, mtx::events::collections::TimelineEvents> decryptedEvents_;
+        static QCache<Index, mtx::events::collections::TimelineEvents> events_;
+        static QCache<IdIndex, mtx::events::collections::TimelineEvents> 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 <QObject>
+#include <QString>
+
+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 <Cache.h>
-#include <MatrixClient.h>
-
-QHash<int, QByteArray>
-ReactionsModel::roleNames() const
-{
-        return {
-          {Key, "key"},
-          {Count, "counter"},
-          {Users, "users"},
-          {SelfReactedEvent, "selfReactedEvent"},
-        };
-}
-
-int
-ReactionsModel::rowCount(const QModelIndex &) const
-{
-        return static_cast<int>(reactions.size());
-}
-
-QVariant
-ReactionsModel::data(const QModelIndex &index, int role) const
-{
-        const int i = index.row();
-        if (i < 0 || i >= static_cast<int>(reactions.size()))
-                return {};
-
-        switch (role) {
-        case Key:
-                return QString::fromStdString(reactions[i].key);
-        case Count:
-                return static_cast<int>(reactions[i].reactions.size());
-        case Users: {
-                QString users;
-                bool first = true;
-                for (const auto &reaction : reactions[i].reactions) {
-                        if (!first)
-                                users += ", ";
-                        else
-                                first = false;
-                        users += QString::fromStdString(
-                          cache::displayName(room_id_, reaction.second.sender));
-                }
-                return users;
-        }
-        case 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<mtx::events::msg::Reaction> &reaction)
-{
-        room_id_ = room_id;
-
-        int idx = 0;
-        for (auto &storedReactions : reactions) {
-                if (storedReactions.key == reaction.content.relates_to.key) {
-                        storedReactions.reactions[reaction.event_id] = reaction;
-                        emit dataChanged(index(idx, 0), index(idx, 0));
-                        return;
-                }
-                idx++;
-        }
-
-        beginInsertRows(QModelIndex(), idx, idx);
-        reactions.push_back(
-          KeyReaction{reaction.content.relates_to.key, {{reaction.event_id, reaction}}});
-        endInsertRows();
-}
-
-void
-ReactionsModel::removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction)
-{
-        int idx = 0;
-        for (auto &storedReactions : reactions) {
-                if (storedReactions.key == reaction.content.relates_to.key) {
-                        storedReactions.reactions.erase(reaction.event_id);
-
-                        if (storedReactions.reactions.size() == 0) {
-                                beginRemoveRows(QModelIndex(), idx, idx);
-                                reactions.erase(reactions.begin() + idx);
-                                endRemoveRows();
-                        } else
-                                emit dataChanged(index(idx, 0), index(idx, 0));
-                        return;
-                }
-                idx++;
-        }
-}
diff --git a/src/timeline/ReactionsModel.h b/src/timeline/ReactionsModel.h
deleted file mode 100644
index c839afc8..00000000
--- a/src/timeline/ReactionsModel.h
+++ /dev/null
@@ -1,41 +0,0 @@
-#pragma once
-
-#include <QAbstractListModel>
-#include <QHash>
-
-#include <utility>
-#include <vector>
-
-#include <mtx/events/collections.hpp>
-
-class ReactionsModel : public QAbstractListModel
-{
-        Q_OBJECT
-public:
-        explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); }
-        enum Roles
-        {
-                Key,
-                Count,
-                Users,
-                SelfReactedEvent,
-        };
-
-        QHash<int, QByteArray> roleNames() const override;
-        int rowCount(const QModelIndex &parent = QModelIndex()) const override;
-        QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
-
-public slots:
-        void addReaction(const std::string &room_id,
-                         const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
-        void removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
-
-private:
-        struct KeyReaction
-        {
-                std::string key;
-                std::map<std::string, mtx::events::RoomEvent<mtx::events::msg::Reaction>> reactions;
-        };
-        std::string room_id_;
-        std::vector<KeyReaction> reactions;
-};
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 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<mtx::events::msg::CallCandidates> &)
+        {
+                return qml_mtx_events::EventType::CallCandidates;
+        }
         // ::EventType::Type operator()(const Event<mtx::events::msg::Location> &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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&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,
@@ -228,27 +173,44 @@ TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObj
           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<int, QByteArray>
@@ -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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&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<mtx::events::msg::Encrypted>>(events[id]);
+                auto id              = event_id(event);
+                auto encrypted_event = events.get(id, id, false);
+                return encrypted_event &&
+                       std::holds_alternative<
+                         mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
+                         *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<int>(Section)));
-                m.insert(names[Type], data(id, static_cast<int>(Type)));
-                m.insert(names[TypeString], data(id, static_cast<int>(TypeString)));
-                m.insert(names[IsOnlyEmoji], data(id, static_cast<int>(IsOnlyEmoji)));
-                m.insert(names[Body], data(id, static_cast<int>(Body)));
-                m.insert(names[FormattedBody], data(id, static_cast<int>(FormattedBody)));
-                m.insert(names[UserId], data(id, static_cast<int>(UserId)));
-                m.insert(names[UserName], data(id, static_cast<int>(UserName)));
-                m.insert(names[Timestamp], data(id, static_cast<int>(Timestamp)));
-                m.insert(names[Url], data(id, static_cast<int>(Url)));
-                m.insert(names[ThumbnailUrl], data(id, static_cast<int>(ThumbnailUrl)));
-                m.insert(names[Blurhash], data(id, static_cast<int>(Blurhash)));
-                m.insert(names[Filename], data(id, static_cast<int>(Filename)));
-                m.insert(names[Filesize], data(id, static_cast<int>(Filesize)));
-                m.insert(names[MimeType], data(id, static_cast<int>(MimeType)));
-                m.insert(names[Height], data(id, static_cast<int>(Height)));
-                m.insert(names[Width], data(id, static_cast<int>(Width)));
-                m.insert(names[ProportionalHeight], data(id, static_cast<int>(ProportionalHeight)));
-                m.insert(names[Id], data(id, static_cast<int>(Id)));
-                m.insert(names[State], data(id, static_cast<int>(State)));
-                m.insert(names[IsEncrypted], data(id, static_cast<int>(IsEncrypted)));
-                m.insert(names[IsRoomEncrypted], data(id, static_cast<int>(IsRoomEncrypted)));
-                m.insert(names[ReplyTo], data(id, static_cast<int>(ReplyTo)));
-                m.insert(names[RoomName], data(id, static_cast<int>(RoomName)));
-                m.insert(names[RoomTopic], data(id, static_cast<int>(RoomTopic)));
-                m.insert(names[CallType], data(id, static_cast<int>(CallType)));
+                m.insert(names[Type], data(event, static_cast<int>(Type)));
+                m.insert(names[TypeString], data(event, static_cast<int>(TypeString)));
+                m.insert(names[IsOnlyEmoji], data(event, static_cast<int>(IsOnlyEmoji)));
+                m.insert(names[Body], data(event, static_cast<int>(Body)));
+                m.insert(names[FormattedBody], data(event, static_cast<int>(FormattedBody)));
+                m.insert(names[UserId], data(event, static_cast<int>(UserId)));
+                m.insert(names[UserName], data(event, static_cast<int>(UserName)));
+                m.insert(names[Timestamp], data(event, static_cast<int>(Timestamp)));
+                m.insert(names[Url], data(event, static_cast<int>(Url)));
+                m.insert(names[ThumbnailUrl], data(event, static_cast<int>(ThumbnailUrl)));
+                m.insert(names[Blurhash], data(event, static_cast<int>(Blurhash)));
+                m.insert(names[Filename], data(event, static_cast<int>(Filename)));
+                m.insert(names[Filesize], data(event, static_cast<int>(Filesize)));
+                m.insert(names[MimeType], data(event, static_cast<int>(MimeType)));
+                m.insert(names[Height], data(event, static_cast<int>(Height)));
+                m.insert(names[Width], data(event, static_cast<int>(Width)));
+                m.insert(names[ProportionalHeight],
+                         data(event, static_cast<int>(ProportionalHeight)));
+                m.insert(names[Id], data(event, static_cast<int>(Id)));
+                m.insert(names[State], data(event, static_cast<int>(State)));
+                m.insert(names[IsEncrypted], data(event, static_cast<int>(IsEncrypted)));
+                m.insert(names[IsRoomEncrypted], data(event, static_cast<int>(IsRoomEncrypted)));
+                m.insert(names[ReplyTo], data(event, static_cast<int>(ReplyTo)));
+                m.insert(names[RoomName], data(event, static_cast<int>(RoomName)));
+                m.insert(names[RoomTopic], data(event, static_cast<int>(RoomTopic)));
+                m.insert(names[CallType], data(event, static_cast<int>(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 (int r = rowCount() - index.row(); r < events.size(); r++) {
+                        auto tempEv = events.get(r);
+                        if (!tempEv)
+                                break;
 
-                for (size_t r = index.row() + 1; r < eventOrder.size(); r++) {
-                        auto tempEv        = events.value(eventOrder[r]);
-                        QDateTime prevDate = origin_server_ts(tempEv);
+                        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<mtx::events::StateEvent<mtx::events::state::Create>>(
-              events[eventOrder.back()]))
+        if (auto first = events.get(0);
+            first &&
+            !std::holds_alternative<mtx::events::StateEvent<mtx::events::state::Create>>(*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<QString> ids = internalAddEvents(timeline.events, true);
+        events.handleSync(timeline);
 
-        if (!ids.empty()) {
-                beginInsertRows(QModelIndex(), 0, static_cast<int>(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<EncryptedEvent<msg::Encrypted>>(&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 (!timeline.events.empty())
-                updateLastMessage();
+                if (std::holds_alternative<RoomEvent<msg::CallCandidates>>(e) ||
+                    std::holds_alternative<RoomEvent<msg::CallInvite>>(e) ||
+                    std::holds_alternative<RoomEvent<msg::CallAnswer>>(e) ||
+                    std::holds_alternative<RoomEvent<msg::CallHangUp>>(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);
+        }
+        updateLastMessage();
 }
 
 template<typename T>
@@ -649,21 +609,17 @@ isYourJoin(const mtx::events::Event<T> &)
 void
 TimelineModel::updateLastMessage()
 {
-        for (auto it = eventOrder.begin(); it != eventOrder.end(); ++it) {
-                auto event = events.value(*it);
-                if (auto e = std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
-                      &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<QString>
-TimelineModel::internalAddEvents(
-  const std::vector<mtx::events::collections::TimelineEvents> &timeline,
-  bool emitCallEvents)
-{
-        std::vector<QString> 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<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(&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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
-                                &event)) {
-                                QString reactedTo =
-                                  QString::fromStdString(reaction->content.relates_to.event_id);
-                                reactions[reactedTo].removeReaction(*reaction);
-                                int idx = idToIndex(reactedTo);
-                                if (idx >= 0)
-                                        emit dataChanged(index(idx, 0), index(idx, 0));
-                        }
-
-                        if (redacted != eventOrder.end()) {
-                                auto redactedEvent = std::visit(
-                                  [](const auto &ev)
-                                    -> mtx::events::RoomEvent<mtx::events::msg::Redacted> {
-                                          mtx::events::RoomEvent<mtx::events::msg::Redacted>
-                                            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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&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<mtx::events::msg::CallCandidates>>(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<mtx::events::msg::CallInvite>>(&e_)) {
-                                        callInvite->room_id = room_id_.toStdString();
-                                        emit newCallEvent(e_);
-                                } else if (std::holds_alternative<mtx::events::RoomEvent<
-                                             mtx::events::msg::CallCandidates>>(e_) ||
-                                           std::holds_alternative<
-                                             mtx::events::RoomEvent<mtx::events::msg::CallAnswer>>(
-                                             e_) ||
-                                           std::holds_alternative<
-                                             mtx::events::RoomEvent<mtx::events::msg::CallHangUp>>(
-                                             e_)) {
-                                        emit newCallEvent(e_);
-                                }
-                        }
-                }
-
-                if (std::holds_alternative<
-                      mtx::events::RoomEvent<mtx::events::msg::CallCandidates>>(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<mtx::events::msg::CallInvite>>(
-                                &e)) {
-                                callInvite->room_id = room_id_.toStdString();
-                                emit newCallEvent(e);
-                        } else if (std::holds_alternative<
-                                     mtx::events::RoomEvent<mtx::events::msg::CallAnswer>>(e) ||
-                                   std::holds_alternative<
-                                     mtx::events::RoomEvent<mtx::events::msg::CallHangUp>>(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<QString> ids = internalAddEvents(msgs.chunk, false);
-
-        if (!ids.empty()) {
-                beginInsertRows(QModelIndex(),
-                                static_cast<int>(this->eventOrder.size()),
-                                static_cast<int>(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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&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<mtx::events::msg::Encrypted> &e) const
-{
-        static QCache<std::string, DecryptionResult> 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<mtx::events::msg::Notice> 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<mtx::events::collections::TimelineEvents> 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<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&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(
           "<mx-reply>.*</mx-reply>", 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<mtx::events::msg::Encrypted> 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<msg::Encrypted, EventType::RoomEncrypted>(
-                          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<int>(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<StateKeeper>([megolm_payload, room_id, doc, txn_id, this]() {
-                          try {
-                                  auto data = olm::encrypt_group_message(
-                                    room_id, http::client()->device_id(), doc);
-
-                                  http::client()
-                                    ->send_room_message<msg::Encrypted, EventType::RoomEncrypted>(
-                                      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<int>(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));
-                          }
-                  });
+                auto keeper = std::make_shared<StateKeeper>([room_id, doc, txn_id, this]() {
+                        try {
+                                mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> 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();
+
+                                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<int>(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<StateKeeper> keeper,
 
 struct SendMessageVisitor
 {
-        SendMessageVisitor(const QString &txn_id, TimelineModel *model)
-          : txn_id_qstr_(txn_id)
-          , model_(model)
+        explicit SendMessageVisitor(TimelineModel *model)
+          : model_(model)
         {}
 
         template<typename T, mtx::events::EventType Event>
-        void sendRoomEvent(const mtx::events::RoomEvent<T> &msg)
+        void sendRoomEvent(mtx::events::RoomEvent<T> 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<T, Event>(msg);
+                        msg.type = Event;
+                        emit model_->addPendingMessageToStore(msg);
                 }
         }
 
-        template<typename T, mtx::events::EventType Event>
-        void sendUnencryptedRoomEvent(const mtx::events::RoomEvent<T> &msg)
-        {
-                QString txn_id_qstr  = txn_id_qstr_;
-                TimelineModel *model = model_;
-                http::client()->send_room_message<T, Event>(
-                  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<int>(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<typename T>
         void operator()(const mtx::events::Event<T> &)
@@ -1535,7 +1135,7 @@ struct SendMessageVisitor
         // Operator for m.room.message events that contain a msgtype in their content
         template<typename T,
                  std::enable_if_t<std::is_same<decltype(T::msgtype), std::string>::value, int> = 0>
-        void operator()(const mtx::events::RoomEvent<T> &msg)
+        void operator()(mtx::events::RoomEvent<T> msg)
         {
                 sendRoomEvent<T, mtx::events::EventType::RoomMessage>(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<mtx::events::msg::Reaction> &msg)
+        void operator()(mtx::events::RoomEvent<mtx::events::msg::Reaction> msg)
         {
-                sendUnencryptedRoomEvent<mtx::events::msg::Reaction,
-                                         mtx::events::EventType::Reaction>(msg);
+                msg.type = mtx::events::EventType::Reaction;
+                emit model_->addPendingMessageToStore(msg);
         }
 
         void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &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<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&event) &&
-            !std::get_if<mtx::events::RoomEvent<mtx::events::msg::CallCandidates>>(&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);
-
-        if (auto e =
-              std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
+        if (!event)
+                return false;
 
-        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);
-
-        if (auto e =
-              std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) {
-                event = decryptEvent(*e).event;
-        }
+        mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
+        if (!event)
+                return;
 
-        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<QString> &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<mtx::events::StateEvent<mtx::events::state::JoinRules>>(&events[id]);
+        auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::JoinRules>>(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<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(&events[id]);
+        auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(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<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(&events[id]);
+        auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(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<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(&events[id]);
+        auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(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<mtx::events::StateEvent<mtx::events::state::Member>>(&events[id]);
+        auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
         if (!event)
                 return "";
 
         mtx::events::StateEvent<mtx::events::state::Member> *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<mtx::events::StateEvent<mtx::events::state::Member>>(
-                            &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 <mtxclient/http/errors.hpp>
 
 #include "CacheCryptoStructs.h"
-#include "ReactionsModel.h"
+#include "EventStore.h"
 
 namespace mtx::http {
 using RequestErr = const std::optional<mtx::http::ClientError> &;
@@ -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<int, QByteArray> 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<Reaction>());
+                return vec;
+        }
+
         void updateLastMessage();
         void addEvents(const mtx::responses::Timeline &events);
         template<class T>
@@ -214,7 +225,7 @@ public slots:
         void setCurrentIndex(int index);
         int currentIndex() const { return idToIndex(currentId); }
         void markEventsAsRead(const std::vector<QString> &event_ids);
-        QVariantMap getDump(QString eventId) const;
+        QVariantMap getDump(QString eventId, QString relatedTo) const;
         void updateTypingUsers(const std::vector<QString> &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<QString> 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<mtx::events::msg::Encrypted> &e) const;
-        std::vector<QString> internalAddEvents(
-          const std::vector<mtx::events::collections::TimelineEvents> &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<QString, mtx::events::collections::TimelineEvents> events;
         QSet<QString> read;
-        QList<QString> pending;
-        std::vector<QString> eventOrder;
-        std::map<QString, ReactionsModel> 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,36 +340,39 @@ 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,
                                        const std::optional<mtx::crypto::EncryptedFile> &file,
@@ -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;