diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..86924ed3 --- /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 3b352730..12acb35a 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 00000000..dc9583ac --- /dev/null +++ b/include/Cache.h @@ -0,0 +1,57 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * 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 . + */ + +#pragma once + +#include + +#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 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 74db6b15..88d4435e 100644 --- a/include/ChatPage.h +++ b/include/ChatPage.h @@ -21,6 +21,7 @@ #include #include +#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 client_; + + // LMDB wrapper. + QSharedPointer cache_; }; diff --git a/include/RoomState.h b/include/RoomState.h index 0389a6df..1d8ae429 100644 --- a/include/RoomState.h +++ b/include/RoomState.h @@ -17,6 +17,7 @@ #pragma once +#include #include #include @@ -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 aliases; events::StateEvent avatar; diff --git a/include/TimelineView.h b/include/TimelineView.h index b50970b9..ea3e5fb3 100644 --- a/include/TimelineView.h +++ b/include/TimelineView.h @@ -63,6 +63,7 @@ class TimelineView : public QWidget public: TimelineView(const Timeline &timeline, QSharedPointer client, const QString &room_id, QWidget *parent = 0); + TimelineView(QSharedPointer client, const QString &room_id, QWidget *parent = 0); TimelineItem *createTimelineItem(const events::MessageEvent &e, const QString &color, bool with_sender); TimelineItem *createTimelineItem(const events::MessageEvent &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 &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 eventIds_; QList pending_msgs_; QSharedPointer 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 dc2445e2..c5b37542 100644 --- a/include/TimelineViewManager.h +++ b/include/TimelineViewManager.h @@ -34,7 +34,10 @@ public: TimelineViewManager(QSharedPointer client, QWidget *parent); ~TimelineViewManager(); + // Initialize with timeline events. void initialize(const Rooms &rooms); + // Empty initialization. + void initialize(const QList &rooms); void sync(const Rooms &rooms); void clearAll(); diff --git a/libs/lmdbxx b/libs/lmdbxx new file mode 160000 index 00000000..0b43ca87 --- /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 00000000..c9f3fa5f --- /dev/null +++ b/src/Cache.cc @@ -0,0 +1,229 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * 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 . + */ + +#include + +#include +#include +#include +#include + +#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 Cache::states() +{ + QMap 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> 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 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()); +} diff --git a/src/ChatPage.cc b/src/ChatPage.cc index 4e9120d2..5a5a497e 100644 --- a/src/ChatPage.cc +++ b/src/ChatPage.cc @@ -46,7 +46,6 @@ ChatPage::ChatPage(QSharedPointer 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(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(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 649064b8..d7e2a3c0 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 3eaff452..c5e763e7 100644 --- a/src/RoomState.cc +++ b/src/RoomState.cc @@ -16,6 +16,7 @@ */ #include +#include #include #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 event; + + try { + event.deserialize(object["aliases"]); + aliases = event; + } catch (const DeserializationException &e) { + qWarning() << "RoomState::parse - aliases" << e.what(); + } + } + + if (object.contains("avatar")) { + events::StateEvent 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 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 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 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 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 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 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 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 3f7c877e..731e7db5 100644 --- a/src/TimelineView.cc +++ b/src/TimelineView.cc @@ -49,6 +49,18 @@ TimelineView::TimelineView(const Timeline &timeline, addEvents(timeline); } +TimelineView::TimelineView(QSharedPointer 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; - scroll_area_->verticalScrollBar()->setValue(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(); + } +} + +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 f55d4868..3715d1b6 100644 --- a/src/TimelineViewManager.cc +++ b/src/TimelineViewManager.cc @@ -85,6 +85,18 @@ void TimelineViewManager::initialize(const Rooms &rooms) } } +void TimelineViewManager::initialize(const QList &rooms) +{ + for (const auto &roomid : rooms) { + // Create a history view without any events. + TimelineView *view = new TimelineView(client_, roomid); + views_.insert(roomid, QSharedPointer(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 TimelineViewManager::NICK_COLORS;