diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d1bf0ea..7f9204c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ set(SRC_FILES src/MatrixClient.cc src/Profile.cc src/RoomInfoListItem.cc + src/RoomMessages.cc src/RoomList.cc src/RoomState.cc src/Register.cc diff --git a/include/MatrixClient.h b/include/MatrixClient.h index 741294c4..79813c95 100644 --- a/include/MatrixClient.h +++ b/include/MatrixClient.h @@ -21,6 +21,7 @@ #include #include "Profile.h" +#include "RoomMessages.h" #include "Sync.h" /* @@ -43,6 +44,7 @@ public: void fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url); void fetchOwnAvatar(const QUrl &avatar_url); void downloadImage(const QString &event_id, const QUrl &url); + void messages(const QString &room_id, const QString &from_token) noexcept; inline QUrl getHomeServer(); inline int transactionId(); @@ -77,19 +79,21 @@ signals: void syncCompleted(const SyncResponse &response); void syncFailed(const QString &msg); void messageSent(const QString &event_id, const QString &roomid, const int txn_id); + void messagesRetrieved(const QString &room_id, const RoomMessages &msgs); private slots: void onResponse(QNetworkReply *reply); private: enum class Endpoint { - GetOwnProfile, GetOwnAvatar, + GetOwnProfile, GetProfile, Image, InitialSync, Login, Logout, + Messages, Register, RoomAvatar, SendTextMessage, @@ -109,6 +113,7 @@ private: void onSyncResponse(QNetworkReply *reply); void onRoomAvatarResponse(QNetworkReply *reply); void onImageResponse(QNetworkReply *reply); + void onMessagesResponse(QNetworkReply *reply); // Client API prefix. QString api_url_; diff --git a/include/RoomMessages.h b/include/RoomMessages.h new file mode 100644 index 00000000..695580b3 --- /dev/null +++ b/include/RoomMessages.h @@ -0,0 +1,56 @@ +/* + * 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 . + */ + +#ifndef ROOM_MESSAGES_H +#define ROOM_MESSAGES_H + +#include +#include + +#include "Deserializable.h" + +class RoomMessages : public Deserializable +{ +public: + void deserialize(const QJsonDocument &data) override; + + inline QString start() const; + inline QString end() const; + inline QJsonArray chunk() const; + +private: + QString start_; + QString end_; + QJsonArray chunk_; +}; + +inline QString RoomMessages::start() const +{ + return start_; +} + +inline QString RoomMessages::end() const +{ + return end_; +} + +inline QJsonArray RoomMessages::chunk() const +{ + return chunk_; +} + +#endif // ROOM_MESSAGES_H diff --git a/include/TimelineView.h b/include/TimelineView.h index 1808d735..dbc73bbf 100644 --- a/include/TimelineView.h +++ b/include/TimelineView.h @@ -51,32 +51,50 @@ struct PendingMessage { } }; +// In which place new TimelineItems should be inserted. +enum class TimelineDirection { + Top, + Bottom, +}; + class TimelineView : public QWidget { Q_OBJECT public: - TimelineView(QSharedPointer client, QWidget *parent = 0); - TimelineView(const QJsonArray &events, QSharedPointer client, QWidget *parent = 0); - ~TimelineView(); + TimelineView(const Timeline &timeline, QSharedPointer client, const QString &room_id, QWidget *parent = 0); - void addHistoryItem(const events::MessageEvent &e, const QString &color, bool with_sender); - void addHistoryItem(const events::MessageEvent &e, const QString &color, bool with_sender); - void addHistoryItem(const events::MessageEvent &e, const QString &color, bool with_sender); + TimelineItem *createTimelineItem(const events::MessageEvent &e, const QString &color, bool with_sender); + TimelineItem *createTimelineItem(const events::MessageEvent &e, const QString &color, bool with_sender); + TimelineItem *createTimelineItem(const events::MessageEvent &e, const QString &color, bool with_sender); - int addEvents(const QJsonArray &events); + // Add new events at the end of the timeline. + int addEvents(const Timeline &timeline); void addUserTextMessage(const QString &msg, int txn_id); void updatePendingMessage(int txn_id, QString event_id); + void scrollDown(); void clear(); public slots: void sliderRangeChanged(int min, int max); + void sliderMoved(int position); + + // Add old events at the top of the timeline. + void addBackwardsEvents(const QString &room_id, const RoomMessages &msgs); private: void init(); void removePendingMessage(const events::MessageEvent &e); + void addTimelineItem(TimelineItem *item, TimelineDirection direction); + void updateLastSender(const QString &user_id, TimelineDirection direction); + + // 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); + // Return nullptr if the event couldn't be parsed. + TimelineItem *parseMessageEvent(const QJsonObject &event, TimelineDirection direction); + QVBoxLayout *top_layout_; QVBoxLayout *scroll_layout_; @@ -84,6 +102,19 @@ private: QWidget *scroll_widget_; QString last_sender_; + QString last_sender_backwards_; + QString room_id_; + QString prev_batch_token_; + QString local_user_; + + bool isPaginationInProgress_ = false; + bool isInitialized = false; + bool isTimelineFinished = false; + + const int SCROLL_BAR_GAP = 300; + + int scroll_height_ = 0; + int previous_max_height_ = 0; QList pending_msgs_; QSharedPointer client_; diff --git a/src/MainWindow.cc b/src/MainWindow.cc index ce9d1110..bc64198c 100644 --- a/src/MainWindow.cc +++ b/src/MainWindow.cc @@ -108,6 +108,7 @@ void MainWindow::showChatPage(QString userid, QString homeserver, QString token) if (progress_modal_ == nullptr) { progress_modal_ = new OverlayModal(this, spinner_); progress_modal_->fadeIn(); + progress_modal_->setDuration(300); } login_page_->reset(); diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc index ac321d94..015e9809 100644 --- a/src/MatrixClient.cc +++ b/src/MatrixClient.cc @@ -333,6 +333,32 @@ void MatrixClient::onImageResponse(QNetworkReply *reply) emit imageDownloaded(event_id, pixmap); } +void MatrixClient::onMessagesResponse(QNetworkReply *reply) +{ + reply->deleteLater(); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (status == 0 || status >= 400) { + qWarning() << reply->errorString(); + return; + } + + auto data = reply->readAll(); + auto room_id = reply->property("room_id").toString(); + + RoomMessages msgs; + + try { + msgs.deserialize(QJsonDocument::fromJson(data)); + } catch (const DeserializationException &e) { + qWarning() << "Room messages from" << room_id << e.what(); + return; + } + + emit messagesRetrieved(room_id, msgs); +} + void MatrixClient::onResponse(QNetworkReply *reply) { switch (static_cast(reply->property("endpoint").toInt())) { @@ -369,6 +395,9 @@ void MatrixClient::onResponse(QNetworkReply *reply) case Endpoint::GetOwnAvatar: onGetOwnAvatarResponse(reply); break; + case Endpoint::Messages: + onMessagesResponse(reply); + break; default: break; } @@ -581,3 +610,21 @@ void MatrixClient::fetchOwnAvatar(const QUrl &avatar_url) QNetworkReply *reply = get(avatar_request); reply->setProperty("endpoint", static_cast(Endpoint::GetOwnAvatar)); } + +void MatrixClient::messages(const QString &room_id, const QString &from_token) noexcept +{ + QUrlQuery query; + query.addQueryItem("access_token", token_); + query.addQueryItem("from", from_token); + query.addQueryItem("dir", "b"); + + QUrl endpoint(server_); + endpoint.setPath(api_url_ + QString("/rooms/%1/messages").arg(room_id)); + endpoint.setQuery(query); + + QNetworkRequest request(QString(endpoint.toEncoded())); + + QNetworkReply *reply = get(request); + reply->setProperty("endpoint", static_cast(Endpoint::Messages)); + reply->setProperty("room_id", room_id); +} diff --git a/src/RoomMessages.cc b/src/RoomMessages.cc new file mode 100644 index 00000000..0aa020a4 --- /dev/null +++ b/src/RoomMessages.cc @@ -0,0 +1,42 @@ +/* + * 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 "RoomMessages.h" + +void RoomMessages::deserialize(const QJsonDocument &data) +{ + if (!data.isObject()) + throw DeserializationException("response is not a JSON object"); + + QJsonObject object = data.object(); + + if (!object.contains("start")) + throw DeserializationException("start key is missing"); + + if (!object.contains("end")) + throw DeserializationException("end key is missing"); + + if (!object.contains("chunk")) + throw DeserializationException("chunk key is missing"); + + if (!object.value("chunk").isArray()) + throw DeserializationException("chunk isn't a JSON array"); + + start_ = object.value("start").toString(); + end_ = object.value("end").toString(); + chunk_ = object.value("chunk").toArray(); +} diff --git a/src/TimelineView.cc b/src/TimelineView.cc index c57d8f7b..9f80db66 100644 --- a/src/TimelineView.cc +++ b/src/TimelineView.cc @@ -34,19 +34,19 @@ namespace events = matrix::events; namespace msgs = matrix::events::messages; -TimelineView::TimelineView(const QJsonArray &events, QSharedPointer client, QWidget *parent) +TimelineView::TimelineView(const Timeline &timeline, + QSharedPointer client, + const QString &room_id, + QWidget *parent) : QWidget(parent) + , room_id_{room_id} , client_{client} { - init(); - addEvents(events); -} + QSettings settings; + local_user_ = settings.value("auth/user_id").toString(); -TimelineView::TimelineView(QSharedPointer client, QWidget *parent) - : QWidget(parent) - , client_{client} -{ init(); + addEvents(timeline); } void TimelineView::clear() @@ -58,83 +58,175 @@ void TimelineView::clear() void TimelineView::sliderRangeChanged(int min, int max) { Q_UNUSED(min); - scroll_area_->verticalScrollBar()->setValue(max); + + if (!scroll_area_->verticalScrollBar()->isVisible()) + return; + + if (max - scroll_area_->verticalScrollBar()->value() < SCROLL_BAR_GAP) + scroll_area_->verticalScrollBar()->setValue(max); } -int TimelineView::addEvents(const QJsonArray &events) +void TimelineView::scrollDown() { - QSettings settings; - auto local_user = settings.value("auth/user_id").toString(); + int current = scroll_area_->verticalScrollBar()->value(); + int max = scroll_area_->verticalScrollBar()->maximum(); - int message_count = 0; - events::EventType ty; + // The first time we enter the room move the scroll bar to the bottom. + if (!isInitialized) { + scroll_area_->ensureVisible(0, scroll_widget_->size().height(), 0, 0); + isInitialized = true; + return; + } - for (const auto &event : events) { - ty = events::extractEventType(event.toObject()); + // If the gap is small enough move the scroll bar down. e.g when a new message appears. + if (max - current < SCROLL_BAR_GAP) + scroll_area_->verticalScrollBar()->setValue(max); +} - if (ty == events::EventType::RoomMessage) { - events::MessageEventType msg_type = events::extractMessageEventType(event.toObject()); +void TimelineView::sliderMoved(int position) +{ + if (!scroll_area_->verticalScrollBar()->isVisible()) + return; - if (msg_type == events::MessageEventType::Text) { - events::MessageEvent text; + // The scrollbar is high enough so we can start retrieving old events. + if (position < SCROLL_BAR_GAP) { + if (isTimelineFinished) + return; - try { - text.deserialize(event.toObject()); - } catch (const DeserializationException &e) { - qWarning() << e.what() << event; - continue; - } + // Prevent user from moving up when there is pagination in progress. + if (isPaginationInProgress_) { + scroll_area_->verticalScrollBar()->setValue(SCROLL_BAR_GAP); + return; + } - if (isPendingMessage(text, local_user)) { - removePendingMessage(text); - continue; - } + isPaginationInProgress_ = true; + scroll_height_ = scroll_area_->verticalScrollBar()->value(); + previous_max_height_ = scroll_area_->verticalScrollBar()->maximum(); - auto with_sender = last_sender_ != text.sender(); - auto color = TimelineViewManager::getUserColor(text.sender()); + // FIXME: Maybe move this to TimelineViewManager to remove the extra calls? + client_.data()->messages(room_id_, prev_batch_token_); + } +} - addHistoryItem(text, color, with_sender); - last_sender_ = text.sender(); +void TimelineView::addBackwardsEvents(const QString &room_id, const RoomMessages &msgs) +{ + if (room_id_ != room_id) + return; - message_count += 1; - } else if (msg_type == events::MessageEventType::Notice) { - events::MessageEvent notice; + if (msgs.chunk().count() == 0) { + isTimelineFinished = true; + return; + } - try { - notice.deserialize(event.toObject()); - } catch (const DeserializationException &e) { - qWarning() << e.what() << event; - continue; - } + isTimelineFinished = false; + last_sender_backwards_.clear(); + QList items; - auto with_sender = last_sender_ != notice.sender(); - auto color = TimelineViewManager::getUserColor(notice.sender()); + // Parse in reverse order to determine where we should not show sender's name. + auto it = msgs.chunk().constEnd(); + while (it != msgs.chunk().constBegin()) { + --it; - addHistoryItem(notice, color, with_sender); - last_sender_ = notice.sender(); + TimelineItem *item = parseMessageEvent((*it).toObject(), TimelineDirection::Top); - message_count += 1; - } else if (msg_type == events::MessageEventType::Image) { - events::MessageEvent img; + if (item != nullptr) + items.push_back(item); + } - try { - img.deserialize(event.toObject()); - } catch (const DeserializationException &e) { - qWarning() << e.what() << event; - continue; - } + // Reverse again to render them. + std::reverse(items.begin(), items.end()); - auto with_sender = last_sender_ != img.sender(); - auto color = TimelineViewManager::getUserColor(img.sender()); + for (const auto &item : items) + addTimelineItem(item, TimelineDirection::Top); - addHistoryItem(img, color, with_sender); + prev_batch_token_ = msgs.end(); + isPaginationInProgress_ = false; +} - last_sender_ = img.sender(); - message_count += 1; - } else if (msg_type == events::MessageEventType::Unknown) { - qWarning() << "Unknown message type" << event.toObject(); - continue; +TimelineItem *TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection direction) +{ + events::EventType ty = events::extractEventType(event); + + if (ty == events::EventType::RoomMessage) { + events::MessageEventType msg_type = events::extractMessageEventType(event); + + if (msg_type == events::MessageEventType::Text) { + events::MessageEvent text; + + try { + text.deserialize(event); + } catch (const DeserializationException &e) { + qWarning() << e.what() << event; + return nullptr; } + + if (isPendingMessage(text, local_user_)) { + removePendingMessage(text); + return nullptr; + } + + auto with_sender = isSenderRendered(text.sender(), direction); + updateLastSender(text.sender(), direction); + + auto color = TimelineViewManager::getUserColor(text.sender()); + last_sender_ = text.sender(); + + return createTimelineItem(text, color, with_sender); + } else if (msg_type == events::MessageEventType::Notice) { + events::MessageEvent notice; + + try { + notice.deserialize(event); + } catch (const DeserializationException &e) { + qWarning() << e.what() << event; + return nullptr; + } + + auto with_sender = isSenderRendered(notice.sender(), direction); + updateLastSender(notice.sender(), direction); + + auto color = TimelineViewManager::getUserColor(notice.sender()); + last_sender_ = notice.sender(); + + return createTimelineItem(notice, color, with_sender); + } else if (msg_type == events::MessageEventType::Image) { + events::MessageEvent img; + + try { + img.deserialize(event); + } catch (const DeserializationException &e) { + qWarning() << e.what() << event; + return nullptr; + } + + auto with_sender = isSenderRendered(img.sender(), direction); + updateLastSender(img.sender(), direction); + + auto color = TimelineViewManager::getUserColor(img.sender()); + last_sender_ = img.sender(); + + return createTimelineItem(img, color, with_sender); + } else if (msg_type == events::MessageEventType::Unknown) { + qWarning() << "Unknown message type" << event; + return nullptr; + } + } + + return nullptr; +} + +int TimelineView::addEvents(const Timeline &timeline) +{ + int message_count = 0; + + prev_batch_token_ = timeline.previousBatch(); + + for (const auto &event : timeline.events()) { + TimelineItem *item = parseMessageEvent(event.toObject(), TimelineDirection::Bottom); + + if (item != nullptr) { + message_count += 1; + addTimelineItem(item, TimelineDirection::Bottom); } } @@ -165,35 +257,59 @@ void TimelineView::init() setLayout(top_layout_); - connect(scroll_area_->verticalScrollBar(), - SIGNAL(rangeChanged(int, int)), - this, - SLOT(sliderRangeChanged(int, int))); + connect(client_.data(), &MatrixClient::messagesRetrieved, this, &TimelineView::addBackwardsEvents); + + connect(scroll_area_->verticalScrollBar(), SIGNAL(valueChanged(int)), this, SLOT(sliderMoved(int))); + connect(scroll_area_->verticalScrollBar(), SIGNAL(rangeChanged(int, int)), this, SLOT(sliderRangeChanged(int, int))); } -void TimelineView::addHistoryItem(const events::MessageEvent &event, const QString &color, bool with_sender) +void TimelineView::updateLastSender(const QString &user_id, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + last_sender_ = user_id; + else + last_sender_backwards_ = user_id; +} + +bool TimelineView::isSenderRendered(const QString &user_id, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + return last_sender_ != user_id; + else + return last_sender_backwards_ != user_id; +} + +TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent &event, const QString &color, bool with_sender) { auto image = new ImageItem(client_, event); if (with_sender) { auto item = new TimelineItem(image, event, color, scroll_widget_); - scroll_layout_->addWidget(item); - } else { - auto item = new TimelineItem(image, event, scroll_widget_); - scroll_layout_->addWidget(item); + return item; } + + auto item = new TimelineItem(image, event, scroll_widget_); + return item; } -void TimelineView::addHistoryItem(const events::MessageEvent &event, const QString &color, bool with_sender) +TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent &event, const QString &color, bool with_sender) { TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_); - scroll_layout_->addWidget(item); + return item; } -void TimelineView::addHistoryItem(const events::MessageEvent &event, const QString &color, bool with_sender) +TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent &event, const QString &color, bool with_sender) { TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_); - scroll_layout_->addWidget(item); + return item; +} + +void TimelineView::addTimelineItem(TimelineItem *item, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + scroll_layout_->addWidget(item); + else + scroll_layout_->insertWidget(0, item); } void TimelineView::updatePendingMessage(int txn_id, QString event_id) @@ -254,7 +370,3 @@ void TimelineView::addUserTextMessage(const QString &body, int txn_id) pending_msgs_.push_back(message); } - -TimelineView::~TimelineView() -{ -} diff --git a/src/TimelineViewManager.cc b/src/TimelineViewManager.cc index 84bf20b2..d07e8075 100644 --- a/src/TimelineViewManager.cc +++ b/src/TimelineViewManager.cc @@ -78,10 +78,9 @@ void TimelineViewManager::initialize(const Rooms &rooms) { for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); it++) { auto roomid = it.key(); - auto events = it.value().timeline().events(); // Create a history view with the room events. - TimelineView *view = new TimelineView(events, client_); + TimelineView *view = new TimelineView(it.value().timeline(), client_, it.key()); views_.insert(it.key(), view); // Add the view in the widget stack. @@ -100,9 +99,8 @@ void TimelineViewManager::sync(const Rooms &rooms) } auto view = views_.value(roomid); - auto events = it.value().timeline().events(); - int msgs_added = view->addEvents(events); + int msgs_added = view->addEvents(it.value().timeline()); if (msgs_added > 0) { // TODO: When the app window gets active the current @@ -124,6 +122,7 @@ void TimelineViewManager::setHistoryView(const QString &room_id) active_room_ = room_id; auto widget = views_.value(room_id); + widget->scrollDown(); setCurrentWidget(widget); } diff --git a/src/main.cc b/src/main.cc index 867ed204..8f5dba1e 100644 --- a/src/main.cc +++ b/src/main.cc @@ -43,7 +43,7 @@ int main(int argc, char *argv[]) app.setStyleSheet( "QScrollBar:vertical { background-color: #f8fbfe; width: 8px; border: none; margin: 2px; }" - "QScrollBar::handle:vertical { background-color : #d6dde3; }" + "QScrollBar::handle:vertical { min-height: 40px; background-color : #d6dde3; }" "QScrollBar::add-line:vertical { border: none; background: none; }" "QScrollBar::sub-line:vertical { border: none; background: none; }");