From 8d05073547d02e105712713de39e8359980812c2 Mon Sep 17 00:00:00 2001
From: Konstantinos Sideris <sideris.konstantin@gmail.com>
Date: Sat, 29 Jul 2017 11:49:00 +0300
Subject: [PATCH] Initial support for state cache

- Adds detection for duplicate events
---
 .gitmodules                   |   3 +
 CMakeLists.txt                |  13 +-
 include/Cache.h               |  57 +++++++++
 include/ChatPage.h            |   6 +
 include/RoomState.h           |   4 +
 include/TimelineView.h        |  10 ++
 include/TimelineViewManager.h |   3 +
 libs/lmdbxx                   |   1 +
 src/Cache.cc                  | 229 ++++++++++++++++++++++++++++++++++
 src/ChatPage.cc               | 106 +++++++++++++++-
 src/MainWindow.cc             |   5 +-
 src/RoomState.cc              | 139 +++++++++++++++++++++
 src/TimelineView.cc           |  52 +++++++-
 src/TimelineViewManager.cc    |  19 ++-
 14 files changed, 632 insertions(+), 15 deletions(-)
 create mode 100644 .gitmodules
 create mode 100644 include/Cache.h
 create mode 160000 libs/lmdbxx
 create mode 100644 src/Cache.cc

diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..86924ed3b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "libs/lmdbxx"]
+	path = libs/lmdbxx
+	url = https://github.com/bendiken/lmdbxx
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3b352730a..12acb35a8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -4,6 +4,15 @@ project(nheko CXX)
 
 option(BUILD_TESTS "Build all tests" OFF)
 
+#
+# LMDB
+#
+find_path (LMDB_INCLUDE_DIR NAMES lmdb.h PATHS "$ENV{LMDB_DIR}/include")
+find_library (LMDB_LIBRARY NAMES lmdb PATHS "$ENV{LMDB_DIR}/lib" )
+
+include(FindPackageHandleStandardArgs)
+find_package_handle_standard_args(LMDB DEFAULT_MSG LMDB_INCLUDE_DIR LMDB_LIBRARY)
+
 #
 # Discover Qt dependencies.
 #
@@ -93,6 +102,7 @@ endif()
 set(SRC_FILES
     src/AvatarProvider.cc
     src/ChatPage.cc
+    src/Cache.cc
     src/Deserializable.cc
     src/EmojiCategory.cc
     src/EmojiItemDelegate.cc
@@ -172,6 +182,7 @@ include_directories(include)
 include_directories(include/ui)
 include_directories(include/events)
 include_directories(include/events/messages)
+include_directories(libs/lmdbxx)
 
 qt5_wrap_cpp(MOC_HEADERS
     include/AvatarProvider.h
@@ -273,7 +284,7 @@ else()
     #
     # Build the executable.
     #
-    SET (NHEKO_LIBS matrix_events Qt5::Widgets Qt5::Network)
+    set (NHEKO_LIBS matrix_events Qt5::Widgets Qt5::Network ${LMDB_LIBRARY})
     set (NHEKO_DEPS ${OS_BUNDLE} ${SRC_FILES} ${UI_HEADERS} ${MOC_HEADERS} ${QRC} ${LANG_QRC} ${QM_SRC})
 
     if(APPLE)
diff --git a/include/Cache.h b/include/Cache.h
new file mode 100644
index 000000000..dc9583ac9
--- /dev/null
+++ b/include/Cache.h
@@ -0,0 +1,57 @@
+/*
+ * nheko Copyright (C) 2017  Konstantinos Sideris <siderisk@auth.gr>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <lmdb++.h>
+
+#include "RoomState.h"
+
+class Cache
+{
+public:
+	Cache(const QString &userId);
+
+	void insertRoomState(const QString &roomid, const RoomState &state);
+	void setNextBatchToken(const QString &token);
+	bool isInitialized();
+
+	QString nextBatchToken();
+	QMap<QString, RoomState> states();
+
+	inline void unmount();
+	inline QString memberDbName(const QString &roomid);
+
+private:
+	lmdb::env env_;
+	lmdb::dbi stateDb_;
+	lmdb::dbi roomDb_;
+
+	bool isMounted_;
+
+	QString userId_;
+};
+
+inline void Cache::unmount()
+{
+	isMounted_ = false;
+}
+
+inline QString Cache::memberDbName(const QString &roomid)
+{
+	return QString("m.%1").arg(roomid);
+}
diff --git a/include/ChatPage.h b/include/ChatPage.h
index 74db6b151..88d4435ec 100644
--- a/include/ChatPage.h
+++ b/include/ChatPage.h
@@ -21,6 +21,7 @@
 #include <QTimer>
 #include <QWidget>
 
+#include "Cache.h"
 #include "MatrixClient.h"
 #include "RoomList.h"
 #include "RoomSettings.h"
@@ -43,6 +44,7 @@ public:
 	void bootstrap(QString userid, QString homeserver, QString token);
 
 signals:
+	void contentLoaded();
 	void close();
 	void changeWindowTitle(const QString &msg);
 	void unreadMessages(int count);
@@ -62,6 +64,7 @@ private slots:
 private:
 	void updateDisplayNames(const RoomState &state);
 	void updateRoomState(RoomState &room_state, const QJsonArray &events);
+	void loadStateFromCache();
 
 	QHBoxLayout *topLayout_;
 	Splitter *splitter;
@@ -97,4 +100,7 @@ private:
 
 	// Matrix Client API provider.
 	QSharedPointer<MatrixClient> client_;
+
+	// LMDB wrapper.
+	QSharedPointer<Cache> cache_;
 };
diff --git a/include/RoomState.h b/include/RoomState.h
index 0389a6dff..1d8ae429e 100644
--- a/include/RoomState.h
+++ b/include/RoomState.h
@@ -17,6 +17,7 @@
 
 #pragma once
 
+#include <QJsonDocument>
 #include <QPixmap>
 #include <QUrl>
 
@@ -45,6 +46,7 @@ public:
 	// e.g If the room is 1-on-1 name and avatar should be extracted from a user.
 	void resolveName();
 	void resolveAvatar();
+	void parse(const QJsonObject &object);
 
 	inline QUrl getAvatar() const;
 	inline QString getName() const;
@@ -53,6 +55,8 @@ public:
 	void removeLeaveMemberships();
 	void update(const RoomState &state);
 
+	QJsonObject serialize() const;
+
 	// The latest state events.
 	events::StateEvent<events::AliasesEventContent> aliases;
 	events::StateEvent<events::AvatarEventContent> avatar;
diff --git a/include/TimelineView.h b/include/TimelineView.h
index b50970b90..ea3e5fb31 100644
--- a/include/TimelineView.h
+++ b/include/TimelineView.h
@@ -63,6 +63,7 @@ class TimelineView : public QWidget
 
 public:
 	TimelineView(const Timeline &timeline, QSharedPointer<MatrixClient> client, const QString &room_id, QWidget *parent = 0);
+	TimelineView(QSharedPointer<MatrixClient> client, const QString &room_id, QWidget *parent = 0);
 
 	TimelineItem *createTimelineItem(const events::MessageEvent<msgs::Image> &e, const QString &color, bool with_sender);
 	TimelineItem *createTimelineItem(const events::MessageEvent<msgs::Notice> &e, const QString &color, bool with_sender);
@@ -72,6 +73,7 @@ public:
 	int addEvents(const Timeline &timeline);
 	void addUserTextMessage(const QString &msg, int txn_id);
 	void updatePendingMessage(int txn_id, QString event_id);
+	void fetchHistory();
 	void scrollDown();
 
 public slots:
@@ -90,6 +92,7 @@ private:
 	// Used to determine whether or not we should prefix a message with the sender's name.
 	bool isSenderRendered(const QString &user_id, TimelineDirection direction);
 	bool isPendingMessage(const events::MessageEvent<msgs::Text> &e, const QString &userid);
+	inline bool isDuplicate(const QString &event_id);
 
 	// Return nullptr if the event couldn't be parsed.
 	TimelineItem *parseMessageEvent(const QJsonObject &event, TimelineDirection direction);
@@ -121,6 +124,13 @@ private:
 	int oldPosition_;
 	int oldHeight_;
 
+	// The events currently rendered. Used for duplicate detection.
+	QMap<QString, bool> eventIds_;
 	QList<PendingMessage> pending_msgs_;
 	QSharedPointer<MatrixClient> client_;
 };
+
+inline bool TimelineView::isDuplicate(const QString &event_id)
+{
+	return eventIds_.contains(event_id);
+}
diff --git a/include/TimelineViewManager.h b/include/TimelineViewManager.h
index dc2445e2b..c5b37542b 100644
--- a/include/TimelineViewManager.h
+++ b/include/TimelineViewManager.h
@@ -34,7 +34,10 @@ public:
 	TimelineViewManager(QSharedPointer<MatrixClient> client, QWidget *parent);
 	~TimelineViewManager();
 
+	// Initialize with timeline events.
 	void initialize(const Rooms &rooms);
+	// Empty initialization.
+	void initialize(const QList<QString> &rooms);
 	void sync(const Rooms &rooms);
 	void clearAll();
 
diff --git a/libs/lmdbxx b/libs/lmdbxx
new file mode 160000
index 000000000..0b43ca87d
--- /dev/null
+++ b/libs/lmdbxx
@@ -0,0 +1 @@
+Subproject commit 0b43ca87d8cfabba392dfe884eb1edb83874de02
diff --git a/src/Cache.cc b/src/Cache.cc
new file mode 100644
index 000000000..c9f3fa5f3
--- /dev/null
+++ b/src/Cache.cc
@@ -0,0 +1,229 @@
+/*
+ * nheko Copyright (C) 2017  Konstantinos Sideris <siderisk@auth.gr>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdexcept>
+
+#include <QDebug>
+#include <QDir>
+#include <QFile>
+#include <QStandardPaths>
+
+#include "Cache.h"
+#include "MemberEventContent.h"
+
+namespace events = matrix::events;
+
+static const lmdb::val NEXT_BATCH_KEY("next_batch");
+static const lmdb::val transactionID("transaction_id");
+
+Cache::Cache(const QString &userId)
+    : env_{nullptr}
+    , stateDb_{0}
+    , roomDb_{0}
+    , isMounted_{false}
+    , userId_{userId}
+{
+	auto statePath = QString("%1/%2/state")
+				 .arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation))
+				 .arg(QString::fromUtf8(userId_.toUtf8().toHex()));
+
+	bool isInitial = !QFile::exists(statePath);
+
+	env_ = lmdb::env::create();
+	env_.set_mapsize(128UL * 1024UL * 1024UL); /* 128 MB */
+	env_.set_max_dbs(1024UL);
+
+	if (isInitial) {
+		qDebug() << "[cache] First time initializing LMDB";
+
+		if (!QDir().mkpath(statePath)) {
+			throw std::runtime_error(("Unable to create state directory:" + statePath).toStdString().c_str());
+		}
+	}
+
+	try {
+		env_.open(statePath.toStdString().c_str());
+	} catch (const lmdb::error &e) {
+		if (e.code() != MDB_VERSION_MISMATCH && e.code() != MDB_INVALID) {
+			throw std::runtime_error("LMDB initialization failed" + std::string(e.what()));
+		}
+
+		qWarning() << "Resetting cache due to LMDB version mismatch:" << e.what();
+
+		QDir stateDir(statePath);
+
+		for (const auto &file : stateDir.entryList(QDir::NoDotAndDotDot)) {
+			if (!stateDir.remove(file))
+				throw std::runtime_error(("Unable to delete file " + file).toStdString().c_str());
+		}
+
+		env_.open(statePath.toStdString().c_str());
+	}
+
+	auto txn = lmdb::txn::begin(env_);
+	stateDb_ = lmdb::dbi::open(txn, "state", MDB_CREATE);
+	roomDb_ = lmdb::dbi::open(txn, "rooms", MDB_CREATE);
+
+	txn.commit();
+
+	isMounted_ = true;
+}
+
+void Cache::insertRoomState(const QString &roomid, const RoomState &state)
+{
+	if (!isMounted_)
+		return;
+
+	auto txn = lmdb::txn::begin(env_);
+
+	auto stateEvents = QJsonDocument(state.serialize()).toBinaryData();
+	auto id = roomid.toUtf8();
+
+	lmdb::dbi_put(
+		txn,
+		roomDb_,
+		lmdb::val(id.data(), id.size()),
+		lmdb::val(stateEvents.data(), stateEvents.size()));
+
+	for (const auto &membership : state.memberships) {
+		lmdb::dbi membersDb = lmdb::dbi::open(txn, roomid.toStdString().c_str(), MDB_CREATE);
+
+		// The user_id this membership event relates to, is used
+		// as the index on the membership database.
+		auto key = membership.stateKey().toUtf8();
+		auto memberEvent = QJsonDocument(membership.serialize()).toBinaryData();
+
+		switch (membership.content().membershipState()) {
+		// We add or update (e.g invite -> join) a new user to the membership list.
+		case events::Membership::Invite:
+		case events::Membership::Join: {
+			lmdb::dbi_put(
+				txn,
+				membersDb,
+				lmdb::val(key.data(), key.size()),
+				lmdb::val(memberEvent.data(), memberEvent.size()));
+			break;
+		}
+		// We remove the user from the membership list.
+		case events::Membership::Leave:
+		case events::Membership::Ban: {
+			lmdb::dbi_del(
+				txn,
+				membersDb,
+				lmdb::val(key.data(), key.size()),
+				lmdb::val(memberEvent.data(), memberEvent.size()));
+			break;
+		}
+		case events::Membership::Knock: {
+			qWarning() << "Skipping knock membership" << roomid << key;
+			break;
+		}
+		}
+	}
+
+	txn.commit();
+}
+
+QMap<QString, RoomState> Cache::states()
+{
+	QMap<QString, RoomState> states;
+
+	auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+	auto cursor = lmdb::cursor::open(txn, roomDb_);
+
+	std::string room;
+	std::string stateData;
+
+	// Retrieve all the room names.
+	while (cursor.get(room, stateData, MDB_NEXT)) {
+		auto roomid = QString::fromUtf8(room.data(), room.size());
+		auto json = QJsonDocument::fromBinaryData(QByteArray(stateData.data(), stateData.size()));
+
+		RoomState state;
+		state.parse(json.object());
+
+		auto memberDb = lmdb::dbi::open(txn, roomid.toStdString().c_str(), MDB_CREATE);
+		QMap<QString, events::StateEvent<events::MemberEventContent>> members;
+
+		auto memberCursor = lmdb::cursor::open(txn, memberDb);
+
+		std::string memberId;
+		std::string memberContent;
+
+		while (memberCursor.get(memberId, memberContent, MDB_NEXT)) {
+			auto userid = QString::fromUtf8(memberId.data(), memberId.size());
+			auto data = QJsonDocument::fromBinaryData(QByteArray(memberContent.data(), memberContent.size()));
+
+			try {
+				events::StateEvent<events::MemberEventContent> member;
+				member.deserialize(data.object());
+				members.insert(userid, member);
+			} catch (const DeserializationException &e) {
+				qWarning() << e.what();
+				qWarning() << "Fault while parsing member event" << data.object();
+				continue;
+			}
+		}
+
+		qDebug() << members.size() << "members for" << roomid;
+
+		state.memberships = members;
+		states.insert(roomid, state);
+	}
+
+	qDebug() << "Retrieved" << states.size() << "rooms";
+
+	cursor.close();
+
+	txn.commit();
+
+	return states;
+}
+
+void Cache::setNextBatchToken(const QString &token)
+{
+	auto txn = lmdb::txn::begin(env_);
+	auto value = token.toUtf8();
+
+	lmdb::dbi_put(txn, stateDb_, NEXT_BATCH_KEY, lmdb::val(value.data(), value.size()));
+
+	txn.commit();
+}
+
+bool Cache::isInitialized()
+{
+	auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+	lmdb::val token;
+
+	bool res = lmdb::dbi_get(txn, stateDb_, NEXT_BATCH_KEY, token);
+
+	txn.commit();
+
+	return res;
+}
+
+QString Cache::nextBatchToken()
+{
+	auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+	lmdb::val token;
+
+	lmdb::dbi_get(txn, stateDb_, NEXT_BATCH_KEY, token);
+
+	txn.commit();
+
+	return QString::fromUtf8(token.data<const char>());
+}
diff --git a/src/ChatPage.cc b/src/ChatPage.cc
index 4e9120d2f..5a5a497ee 100644
--- a/src/ChatPage.cc
+++ b/src/ChatPage.cc
@@ -46,7 +46,6 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client, QWidget *parent)
     , sync_interval_(2000)
     , client_(client)
 {
-	resize(798, 519);
 	setStyleSheet("background-color: #f8fbfe;");
 
 	topLayout_ = new QHBoxLayout(this);
@@ -213,13 +212,22 @@ void ChatPage::logout()
 
 void ChatPage::bootstrap(QString userid, QString homeserver, QString token)
 {
-	Q_UNUSED(userid);
-
 	client_->setServer(homeserver);
 	client_->setAccessToken(token);
-
 	client_->getOwnProfile();
-	client_->initialSync();
+
+	try {
+		cache_ = QSharedPointer<Cache>(new Cache(userid));
+	} catch (const std::exception &e) {
+		qCritical() << e.what();
+	} catch (const lmdb::error &e) {
+		qCritical() << e.what();
+	}
+
+	if (cache_->isInitialized())
+		loadStateFromCache();
+	else
+		client_->initialSync();
 }
 
 void ChatPage::startSync()
@@ -251,6 +259,8 @@ void ChatPage::updateDisplayNames(const RoomState &state)
 
 void ChatPage::syncCompleted(const SyncResponse &response)
 {
+	// TODO: Catch exception
+	cache_->setNextBatchToken(response.nextBatch());
 	client_->setNextBatchToken(response.nextBatch());
 
 	auto joined = response.rooms().join();
@@ -258,6 +268,7 @@ void ChatPage::syncCompleted(const SyncResponse &response)
 	for (auto it = joined.constBegin(); it != joined.constEnd(); it++) {
 		RoomState room_state;
 
+		// Merge the new updates for rooms that we are tracking.
 		if (state_manager_.contains(it.key()))
 			room_state = state_manager_[it.key()];
 
@@ -265,6 +276,15 @@ void ChatPage::syncCompleted(const SyncResponse &response)
 		updateRoomState(room_state, it.value().timeline().events());
 		updateDisplayNames(room_state);
 
+		try {
+			cache_->insertRoomState(it.key(), room_state);
+		} catch (const lmdb::error &e) {
+			qCritical() << e.what();
+			// Stop using the cache if an errors occurs.
+			// TODO: Should also be marked as invalid and be deleted.
+			cache_->unmount();
+		}
+
 		if (state_manager_.contains(it.key())) {
 			// TODO: Use pointers instead of copying.
 			auto oldState = state_manager_[it.key()];
@@ -291,16 +311,32 @@ void ChatPage::initialSyncCompleted(const SyncResponse &response)
 
 	auto joined = response.rooms().join();
 
+	// TODO: Catch exception
+	cache_->setNextBatchToken(response.nextBatch());
+
 	for (auto it = joined.constBegin(); it != joined.constEnd(); it++) {
 		RoomState room_state;
 
+		// Build the current state from the timeline and state events.
 		updateRoomState(room_state, it.value().state().events());
 		updateRoomState(room_state, it.value().timeline().events());
 
+		// Remove redundant memberships.
 		room_state.removeLeaveMemberships();
+
+		// Resolve room name and avatar. e.g in case of one-to-one chats.
 		room_state.resolveName();
 		room_state.resolveAvatar();
 
+		try {
+			cache_->insertRoomState(it.key(), room_state);
+		} catch (const lmdb::error &e) {
+			qCritical() << e.what();
+			// Stop using the cache if an errors occurs.
+			// TODO: Should also be marked as invalid and be deleted.
+			cache_->unmount();
+		}
+
 		updateDisplayNames(room_state);
 
 		state_manager_.insert(it.key(), room_state);
@@ -315,10 +351,15 @@ void ChatPage::initialSyncCompleted(const SyncResponse &response)
 		}
 	}
 
+	// Populate timelines with messages.
 	view_manager_->initialize(response.rooms());
+
+	// Initialize room list.
 	room_list_->setInitialRooms(settingsManager_, state_manager_);
 
 	sync_timer_->start(sync_interval_);
+
+	emit contentLoaded();
 }
 
 void ChatPage::updateTopBarAvatar(const QString &roomid, const QPixmap &img)
@@ -463,6 +504,61 @@ void ChatPage::updateRoomState(RoomState &room_state, const QJsonArray &events)
 	}
 }
 
+void ChatPage::loadStateFromCache()
+{
+	qDebug() << "Restoring state from cache";
+
+	try {
+		qDebug() << "Restored nextBatchToken" << cache_->nextBatchToken();
+		client_->setNextBatchToken(cache_->nextBatchToken());
+	} catch (const lmdb::error &e) {
+		qCritical() << "Failed to load next_batch_token from cache" << e.what();
+		// TODO: Clean the environment
+		return;
+	}
+
+	// Fetch all the joined room's state.
+	auto rooms = cache_->states();
+
+	for (auto it = rooms.constBegin(); it != rooms.constEnd(); it++) {
+		RoomState room_state = it.value();
+
+		// Clean up and prepare state for use.
+		room_state.removeLeaveMemberships();
+		room_state.resolveName();
+		room_state.resolveAvatar();
+
+		// Update the global list with user's display names.
+		updateDisplayNames(room_state);
+
+		// Save the current room state.
+		state_manager_.insert(it.key(), room_state);
+
+		// Create or restore the settings for this room.
+		settingsManager_.insert(it.key(), QSharedPointer<RoomSettings>(new RoomSettings(it.key())));
+
+		// Resolve user avatars.
+		for (const auto membership : room_state.memberships) {
+			auto uid = membership.sender();
+			auto url = membership.content().avatarUrl();
+
+			if (!url.toString().isEmpty())
+				AvatarProvider::setAvatarUrl(uid, url);
+		}
+	}
+
+	// Initializing empty timelines.
+	view_manager_->initialize(rooms.keys());
+
+	// Initialize room list from the restored state and settings.
+	room_list_->setInitialRooms(settingsManager_, state_manager_);
+
+	// Remove the spinner overlay.
+	emit contentLoaded();
+
+	sync_timer_->start(sync_interval_);
+}
+
 ChatPage::~ChatPage()
 {
 	sync_timer_->stop();
diff --git a/src/MainWindow.cc b/src/MainWindow.cc
index 649064b8d..d7e2a3c0e 100644
--- a/src/MainWindow.cc
+++ b/src/MainWindow.cc
@@ -76,10 +76,7 @@ MainWindow::MainWindow(QWidget *parent)
 		this,
 		SLOT(iconActivated(QSystemTrayIcon::ActivationReason)));
 
-	connect(client_.data(),
-		SIGNAL(initialSyncCompleted(const SyncResponse &)),
-		this,
-		SLOT(removeOverlayProgressBar()));
+	connect(chat_page_, SIGNAL(contentLoaded()), this, SLOT(removeOverlayProgressBar()));
 
 	connect(client_.data(),
 		SIGNAL(loginSuccess(QString, QString, QString)),
diff --git a/src/RoomState.cc b/src/RoomState.cc
index 3eaff4524..c5e763e7b 100644
--- a/src/RoomState.cc
+++ b/src/RoomState.cc
@@ -16,6 +16,7 @@
  */
 
 #include <QDebug>
+#include <QJsonArray>
 #include <QSettings>
 
 #include "RoomState.h"
@@ -150,3 +151,141 @@ void RoomState::update(const RoomState &state)
 	if (needsAvatarCalculation)
 		resolveAvatar();
 }
+
+QJsonObject RoomState::serialize() const
+{
+	QJsonObject obj;
+
+	if (!aliases.eventId().isEmpty())
+		obj["aliases"] = aliases.serialize();
+
+	if (!avatar.eventId().isEmpty())
+		obj["avatar"] = avatar.serialize();
+
+	if (!canonical_alias.eventId().isEmpty())
+		obj["canonical_alias"] = canonical_alias.serialize();
+
+	if (!create.eventId().isEmpty())
+		obj["create"] = create.serialize();
+
+	if (!history_visibility.eventId().isEmpty())
+		obj["history_visibility"] = history_visibility.serialize();
+
+	if (!join_rules.eventId().isEmpty())
+		obj["join_rules"] = join_rules.serialize();
+
+	if (!name.eventId().isEmpty())
+		obj["name"] = name.serialize();
+
+	if (!power_levels.eventId().isEmpty())
+		obj["power_levels"] = power_levels.serialize();
+
+	if (!topic.eventId().isEmpty())
+		obj["topic"] = topic.serialize();
+
+	return obj;
+}
+
+void RoomState::parse(const QJsonObject &object)
+{
+	// FIXME: Make this less versbose.
+	
+	if (object.contains("aliases")) {
+		events::StateEvent<events::AliasesEventContent> event;
+
+		try {
+			event.deserialize(object["aliases"]);
+			aliases = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - aliases" << e.what();
+		}
+	}
+
+	if (object.contains("avatar")) {
+		events::StateEvent<events::AvatarEventContent> event;
+
+		try {
+			event.deserialize(object["avatar"]);
+			avatar = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - avatar" << e.what();
+		}
+	}
+
+	if (object.contains("canonical_alias")) {
+		events::StateEvent<events::CanonicalAliasEventContent> event;
+
+		try {
+			event.deserialize(object["canonical_alias"]);
+			canonical_alias = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - canonical_alias" << e.what();
+		}
+	}
+
+	if (object.contains("create")) {
+		events::StateEvent<events::CreateEventContent> event;
+
+		try {
+			event.deserialize(object["create"]);
+			create = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - create" << e.what();
+		}
+	}
+
+	if (object.contains("history_visibility")) {
+		events::StateEvent<events::HistoryVisibilityEventContent> event;
+
+		try {
+			event.deserialize(object["history_visibility"]);
+			history_visibility = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - history_visibility" << e.what();
+		}
+	}
+
+	if (object.contains("join_rules")) {
+		events::StateEvent<events::JoinRulesEventContent> event;
+
+		try {
+			event.deserialize(object["join_rules"]);
+			join_rules = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - join_rules" << e.what();
+		}
+	}
+
+	if (object.contains("name")) {
+		events::StateEvent<events::NameEventContent> event;
+
+		try {
+			event.deserialize(object["name"]);
+			name = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - name" << e.what();
+		}
+	}
+
+	if (object.contains("power_levels")) {
+		events::StateEvent<events::PowerLevelsEventContent> event;
+
+		try {
+			event.deserialize(object["power_levels"]);
+			power_levels = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - power_levels" << e.what();
+		}
+	}
+
+	if (object.contains("topic")) {
+		events::StateEvent<events::TopicEventContent> event;
+
+		try {
+			event.deserialize(object["topic"]);
+			topic = event;
+		} catch (const DeserializationException &e) {
+			qWarning() << "RoomState::parse - topic" << e.what();
+		}
+	}
+}
diff --git a/src/TimelineView.cc b/src/TimelineView.cc
index 3f7c877e8..731e7db52 100644
--- a/src/TimelineView.cc
+++ b/src/TimelineView.cc
@@ -49,6 +49,18 @@ TimelineView::TimelineView(const Timeline &timeline,
 	addEvents(timeline);
 }
 
+TimelineView::TimelineView(QSharedPointer<MatrixClient> client, const QString &room_id, QWidget *parent)
+    : QWidget(parent)
+    , room_id_{room_id}
+    , client_{client}
+{
+	QSettings settings;
+	local_user_ = settings.value("auth/user_id").toString();
+
+	init();
+	client_->messages(room_id_, "");
+}
+
 void TimelineView::sliderRangeChanged(int min, int max)
 {
 	Q_UNUSED(min);
@@ -64,8 +76,26 @@ void TimelineView::sliderRangeChanged(int min, int max)
 
 		int currentHeight = scroll_widget_->size().height();
 		int diff = currentHeight - oldHeight_;
+		int newPosition = oldPosition_ + diff;
+
+		// Keep the scroll bar to the bottom if we are coming from
+		// an scrollbar without height i.e scrollbar->value() == 0
+		if (oldPosition_ == 0)
+			newPosition = max;
+
+		scroll_area_->verticalScrollBar()->setValue(newPosition);
+		fetchHistory();
+	}
+}
 
-		scroll_area_->verticalScrollBar()->setValue(oldPosition_ + diff);
+void TimelineView::fetchHistory()
+{
+	bool hasEnoughMessages = scroll_area_->verticalScrollBar()->value() != 0;
+
+	if (!hasEnoughMessages && !isTimelineFinished && !isPaginationInProgress_) {
+		isPaginationInProgress_ = true;
+		client_->messages(room_id_, prev_batch_token_);
+		scroll_area_->verticalScrollBar()->setValue(scroll_area_->verticalScrollBar()->maximum());
 	}
 }
 
@@ -139,8 +169,10 @@ void TimelineView::addBackwardsEvents(const QString &room_id, const RoomMessages
 	oldPosition_ = scroll_area_->verticalScrollBar()->value();
 	oldHeight_ = scroll_widget_->size().height();
 
-	for (const auto &item : items)
+	for (const auto &item : items) {
+		item->adjustSize();
 		addTimelineItem(item, TimelineDirection::Top);
+	}
 
 	prev_batch_token_ = msgs.end();
 	isPaginationInProgress_ = false;
@@ -164,6 +196,11 @@ TimelineItem *TimelineView::parseMessageEvent(const QJsonObject &event, Timeline
 				return nullptr;
 			}
 
+			if (isDuplicate(text.eventId()))
+				return nullptr;
+
+			eventIds_[text.eventId()] = true;
+
 			if (isPendingMessage(text, local_user_)) {
 				removePendingMessage(text);
 				return nullptr;
@@ -186,6 +223,12 @@ TimelineItem *TimelineView::parseMessageEvent(const QJsonObject &event, Timeline
 				return nullptr;
 			}
 
+			if (isDuplicate(notice.eventId()))
+				return nullptr;
+			;
+
+			eventIds_[notice.eventId()] = true;
+
 			auto with_sender = isSenderRendered(notice.sender(), direction);
 			updateLastSender(notice.sender(), direction);
 
@@ -203,6 +246,11 @@ TimelineItem *TimelineView::parseMessageEvent(const QJsonObject &event, Timeline
 				return nullptr;
 			}
 
+			if (isDuplicate(img.eventId()))
+				return nullptr;
+
+			eventIds_[img.eventId()] = true;
+
 			auto with_sender = isSenderRendered(img.sender(), direction);
 			updateLastSender(img.sender(), direction);
 
diff --git a/src/TimelineViewManager.cc b/src/TimelineViewManager.cc
index f55d48685..3715d1b63 100644
--- a/src/TimelineViewManager.cc
+++ b/src/TimelineViewManager.cc
@@ -85,6 +85,18 @@ void TimelineViewManager::initialize(const Rooms &rooms)
 	}
 }
 
+void TimelineViewManager::initialize(const QList<QString> &rooms)
+{
+	for (const auto &roomid : rooms) {
+		// Create a history view without any events.
+		TimelineView *view = new TimelineView(client_, roomid);
+		views_.insert(roomid, QSharedPointer<TimelineView>(view));
+
+		// Add the view in the widget stack.
+		addWidget(view);
+	}
+}
+
 void TimelineViewManager::sync(const Rooms &rooms)
 {
 	for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); it++) {
@@ -118,11 +130,12 @@ void TimelineViewManager::setHistoryView(const QString &room_id)
 	}
 
 	active_room_ = room_id;
-	auto widget = views_.value(room_id);
+	auto view = views_.value(room_id);
 
-	setCurrentWidget(widget.data());
+	setCurrentWidget(view.data());
 
-	widget->scrollDown();
+	view->fetchHistory();
+	view->scrollDown();
 }
 
 QMap<QString, QString> TimelineViewManager::NICK_COLORS;
-- 
GitLab