From 95c492bad837b999df409c01ea3253d52951c18c Mon Sep 17 00:00:00 2001 From: Konstantinos Sideris Date: Mon, 5 Jun 2017 02:14:05 +0300 Subject: [PATCH] Experimental support for user avatars in timeline --- CMakeLists.txt | 2 + include/AvatarProvider.h | 47 +++++++ include/MatrixClient.h | 4 + include/TimelineItem.h | 25 +++- src/AvatarProvider.cc | 83 +++++++++++ src/ChatPage.cc | 13 ++ src/MatrixClient.cc | 52 +++++++ src/TimelineItem.cc | 287 ++++++++++++++++++++++++++------------- src/main.cc | 2 - 9 files changed, 413 insertions(+), 102 deletions(-) create mode 100644 include/AvatarProvider.h create mode 100644 src/AvatarProvider.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index f6f88e4f..51e1993b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR CMAKE_CXX_COMPILER_ID MATCHES "GNU") endif() set(SRC_FILES + src/AvatarProvider.cc src/ChatPage.cc src/Deserializable.cc src/EmojiCategory.cc @@ -160,6 +161,7 @@ include_directories(include/events) include_directories(include/events/messages) qt5_wrap_cpp(MOC_HEADERS + include/AvatarProvider.h include/ChatPage.h include/EmojiCategory.h include/EmojiItemDelegate.h diff --git a/include/AvatarProvider.h b/include/AvatarProvider.h new file mode 100644 index 00000000..29c8152b --- /dev/null +++ b/include/AvatarProvider.h @@ -0,0 +1,47 @@ +/* + * 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 +#include +#include + +#include "MatrixClient.h" +#include "TimelineItem.h" + +class AvatarProvider : public QObject +{ + Q_OBJECT + +public: + static void init(QSharedPointer client); + static void resolve(const QString &userId, TimelineItem *item); + static void setAvatarUrl(const QString &userId, const QUrl &url); + + static void clear(); + +private: + static void updateAvatar(const QString &uid, const QImage &img); + + static QSharedPointer client_; + static QMap> toBeResolved_; + + static QMap userAvatars_; + static QMap avatarUrls_; +}; diff --git a/include/MatrixClient.h b/include/MatrixClient.h index 2fdb57f0..2fde1c1e 100644 --- a/include/MatrixClient.h +++ b/include/MatrixClient.h @@ -41,6 +41,7 @@ public: void registerUser(const QString &username, const QString &password, const QString &server) noexcept; void versions() noexcept; void fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url); + void fetchUserAvatar(const QString &userId, const QUrl &avatarUrl); 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; @@ -69,6 +70,7 @@ signals: void registerSuccess(const QString &userid, const QString &homeserver, const QString &token); void roomAvatarRetrieved(const QString &roomid, const QPixmap &img); + void userAvatarRetrieved(const QString &userId, const QImage &img); void ownAvatarRetrieved(const QPixmap &img); void imageDownloaded(const QString &event_id, const QPixmap &img); @@ -95,6 +97,7 @@ private: Messages, Register, RoomAvatar, + UserAvatar, SendTextMessage, Sync, Versions, @@ -111,6 +114,7 @@ private: void onInitialSyncResponse(QNetworkReply *reply); void onSyncResponse(QNetworkReply *reply); void onRoomAvatarResponse(QNetworkReply *reply); + void onUserAvatarResponse(QNetworkReply *reply); void onImageResponse(QNetworkReply *reply); void onMessagesResponse(QNetworkReply *reply); diff --git a/include/TimelineItem.h b/include/TimelineItem.h index 5db823b0..c0cf1c7b 100644 --- a/include/TimelineItem.h +++ b/include/TimelineItem.h @@ -24,6 +24,7 @@ #include "ImageItem.h" #include "Sync.h" +#include "Avatar.h" #include "Image.h" #include "MessageEvent.h" #include "Notice.h" @@ -46,19 +47,35 @@ public: TimelineItem(ImageItem *img, const events::MessageEvent &e, const QString &color, QWidget *parent); TimelineItem(ImageItem *img, const events::MessageEvent &e, QWidget *parent); + void setUserAvatar(const QImage &pixmap); + ~TimelineItem(); private: + void init(); + void generateBody(const QString &body); void generateBody(const QString &userid, const QString &color, const QString &body); void generateTimestamp(const QDateTime &time); + void setupAvatarLayout(const QString &userName); + void setupSimpleLayout(); + QString replaceEmoji(const QString &body); - void setupLayout(); + QHBoxLayout *topLayout_; + QVBoxLayout *sideLayout_; // Avatar or Timestamp + QVBoxLayout *mainLayout_; // Header & Message body - QHBoxLayout *top_layout_; + QHBoxLayout *headerLayout_; // Username (&) Timestamp - QLabel *time_label_; - QLabel *content_label_; + Avatar *userAvatar_; + + QLabel *timestamp_; + QLabel *userName_; + QLabel *body_; + + QFont bodyFont_; + QFont usernameFont_; + QFont timestampFont_; }; diff --git a/src/AvatarProvider.cc b/src/AvatarProvider.cc new file mode 100644 index 00000000..7481b781 --- /dev/null +++ b/src/AvatarProvider.cc @@ -0,0 +1,83 @@ +/* + * 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 "AvatarProvider.h" + +QSharedPointer AvatarProvider::client_; + +QMap AvatarProvider::userAvatars_; +QMap AvatarProvider::avatarUrls_; +QMap> AvatarProvider::toBeResolved_; + +void AvatarProvider::init(QSharedPointer client) +{ + client_ = client; + + connect(client_.data(), &MatrixClient::userAvatarRetrieved, &AvatarProvider::updateAvatar); +} + +void AvatarProvider::updateAvatar(const QString &uid, const QImage &img) +{ + if (toBeResolved_.contains(uid)) { + auto items = toBeResolved_[uid]; + + // Update all the timeline items with the resolved avatar. + for (const auto item : items) + item->setUserAvatar(img); + + toBeResolved_.remove(uid); + } + + userAvatars_.insert(uid, img); +} + +void AvatarProvider::resolve(const QString &userId, TimelineItem *item) +{ + if (userAvatars_.contains(userId)) { + auto img = userAvatars_[userId]; + + item->setUserAvatar(img); + + return; + } + + if (avatarUrls_.contains(userId)) { + // Add the current timeline item to the waiting list for this avatar. + if (!toBeResolved_.contains(userId)) { + client_->fetchUserAvatar(userId, avatarUrls_[userId]); + + QList timelineItems; + timelineItems.push_back(item); + + toBeResolved_.insert(userId, timelineItems); + } else { + toBeResolved_[userId].push_back(item); + } + } +} + +void AvatarProvider::setAvatarUrl(const QString &userId, const QUrl &url) +{ + avatarUrls_.insert(userId, url); +} + +void AvatarProvider::clear() +{ + userAvatars_.clear(); + avatarUrls_.clear(); + toBeResolved_.clear(); +} diff --git a/src/ChatPage.cc b/src/ChatPage.cc index 0b09693b..4e9120d2 100644 --- a/src/ChatPage.cc +++ b/src/ChatPage.cc @@ -27,6 +27,7 @@ #include "AliasesEventContent.h" #include "AvatarEventContent.h" +#include "AvatarProvider.h" #include "CanonicalAliasEventContent.h" #include "CreateEventContent.h" #include "HistoryVisibilityEventContent.h" @@ -173,6 +174,8 @@ ChatPage::ChatPage(QSharedPointer client, QWidget *parent) SIGNAL(ownAvatarRetrieved(const QPixmap &)), this, SLOT(setOwnAvatar(const QPixmap &))); + + AvatarProvider::init(client); } void ChatPage::logout() @@ -203,6 +206,8 @@ void ChatPage::logout() settingsManager_.clear(); room_avatars_.clear(); + AvatarProvider::clear(); + emit close(); } @@ -300,6 +305,14 @@ void ChatPage::initialSyncCompleted(const SyncResponse &response) state_manager_.insert(it.key(), room_state); settingsManager_.insert(it.key(), QSharedPointer(new RoomSettings(it.key()))); + + for (const auto membership : room_state.memberships) { + auto uid = membership.sender(); + auto url = membership.content().avatarUrl(); + + if (!url.toString().isEmpty()) + AvatarProvider::setAvatarUrl(uid, url); + } } view_manager_->initialize(response.rooms()); diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc index a605623f..927db541 100644 --- a/src/MatrixClient.cc +++ b/src/MatrixClient.cc @@ -287,6 +287,29 @@ void MatrixClient::onRoomAvatarResponse(QNetworkReply *reply) emit roomAvatarRetrieved(roomid, pixmap); } +void MatrixClient::onUserAvatarResponse(QNetworkReply *reply) +{ + reply->deleteLater(); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (status == 0 || status >= 400) { + qWarning() << reply->errorString(); + return; + } + + auto data = reply->readAll(); + + if (data.size() == 0) + return; + + auto roomid = reply->property("userid").toString(); + + QImage img; + img.loadFromData(data); + + emit userAvatarRetrieved(roomid, img); +} void MatrixClient::onGetOwnAvatarResponse(QNetworkReply *reply) { reply->deleteLater(); @@ -392,6 +415,9 @@ void MatrixClient::onResponse(QNetworkReply *reply) case Endpoint::RoomAvatar: onRoomAvatarResponse(reply); break; + case Endpoint::UserAvatar: + onUserAvatarResponse(reply); + break; case Endpoint::GetOwnAvatar: onGetOwnAvatarResponse(reply); break; @@ -591,6 +617,32 @@ void MatrixClient::fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url reply->setProperty("endpoint", static_cast(Endpoint::RoomAvatar)); } +void MatrixClient::fetchUserAvatar(const QString &userId, const QUrl &avatarUrl) +{ + QList url_parts = avatarUrl.toString().split("mxc://"); + + if (url_parts.size() != 2) { + qDebug() << "Invalid format for user avatar " << avatarUrl.toString(); + return; + } + + QUrlQuery query; + query.addQueryItem("width", "128"); + query.addQueryItem("height", "128"); + query.addQueryItem("method", "crop"); + + QString media_url = QString("%1/_matrix/media/r0/thumbnail/%2").arg(getHomeServer().toString(), url_parts[1]); + + QUrl endpoint(media_url); + endpoint.setQuery(query); + + QNetworkRequest avatar_request(endpoint); + + QNetworkReply *reply = get(avatar_request); + reply->setProperty("userid", userId); + reply->setProperty("endpoint", static_cast(Endpoint::UserAvatar)); +} + void MatrixClient::downloadImage(const QString &event_id, const QUrl &url) { QNetworkRequest image_request(url); diff --git a/src/TimelineItem.cc b/src/TimelineItem.cc index b1c58e6a..cf8d5e85 100644 --- a/src/TimelineItem.cc +++ b/src/TimelineItem.cc @@ -17,8 +17,10 @@ #include #include +#include #include +#include "AvatarProvider.h" #include "ImageItem.h" #include "TimelineItem.h" #include "TimelineViewManager.h" @@ -29,65 +31,119 @@ static const QString URL_HTML = "\\1setContentsMargins(7, 0, 0, 0); + topLayout_->setSpacing(9); + + topLayout_->addLayout(sideLayout_); + topLayout_->addLayout(mainLayout_, 1); +} + TimelineItem::TimelineItem(const QString &userid, const QString &color, QString body, QWidget *parent) : QWidget(parent) { + init(); + body.replace(URL_REGEX, URL_HTML); + auto displayName = TimelineViewManager::displayName(userid); generateTimestamp(QDateTime::currentDateTime()); - generateBody(TimelineViewManager::displayName(userid), color, body); - setupLayout(); + generateBody(displayName, color, body); + + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(0); + + AvatarProvider::resolve(userid, this); } TimelineItem::TimelineItem(QString body, QWidget *parent) : QWidget(parent) { + init(); + body.replace(URL_REGEX, URL_HTML); generateTimestamp(QDateTime::currentDateTime()); generateBody(body); - setupLayout(); + + setupSimpleLayout(); + + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(2); } TimelineItem::TimelineItem(ImageItem *image, const events::MessageEvent &event, const QString &color, QWidget *parent) : QWidget(parent) { + init(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); + auto displayName = TimelineViewManager::displayName(event.sender()); + generateTimestamp(timestamp); - generateBody(TimelineViewManager::displayName(event.sender()), color, ""); + generateBody(displayName, color, ""); - top_layout_ = new QHBoxLayout(); - top_layout_->setMargin(0); - top_layout_->addWidget(time_label_); + setupAvatarLayout(displayName); - auto right_layout = new QVBoxLayout(); - right_layout->addWidget(content_label_); - right_layout->addWidget(image); + auto imageLayout = new QHBoxLayout(); + imageLayout->addWidget(image); + imageLayout->addStretch(1); - top_layout_->addLayout(right_layout); - top_layout_->addStretch(1); + mainLayout_->addLayout(headerLayout_); + mainLayout_->addLayout(imageLayout); + mainLayout_->setContentsMargins(0, 4, 0, 0); + mainLayout_->setSpacing(0); - setLayout(top_layout_); + AvatarProvider::resolve(event.sender(), this); } TimelineItem::TimelineItem(ImageItem *image, const events::MessageEvent &event, QWidget *parent) : QWidget(parent) { + init(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); generateTimestamp(timestamp); - top_layout_ = new QHBoxLayout(); - top_layout_->setMargin(0); - top_layout_->addWidget(time_label_); - top_layout_->addWidget(image, 1); - top_layout_->addStretch(1); + setupSimpleLayout(); - setLayout(top_layout_); + auto imageLayout = new QHBoxLayout(); + imageLayout->setMargin(0); + imageLayout->addWidget(image); + imageLayout->addStretch(1); + + mainLayout_->addLayout(imageLayout); + mainLayout_->setContentsMargins(0, 4, 0, 0); + mainLayout_->setSpacing(2); } TimelineItem::TimelineItem(const events::MessageEvent &event, bool with_sender, const QString &color, QWidget *parent) : QWidget(parent) { + init(); + auto body = event.content().body().trimmed().toHtmlEscaped(); auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); @@ -96,17 +152,34 @@ TimelineItem::TimelineItem(const events::MessageEvent &event, bool body.replace(URL_REGEX, URL_HTML); body = "" + body + ""; - if (with_sender) - generateBody(TimelineViewManager::displayName(event.sender()), color, body); - else + if (with_sender) { + auto displayName = TimelineViewManager::displayName(event.sender()); + + generateBody(displayName, color, body); + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(0); + + AvatarProvider::resolve(event.sender(), this); + } else { generateBody(body); - setupLayout(); + setupSimpleLayout(); + + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(2); + } } TimelineItem::TimelineItem(const events::MessageEvent &event, bool with_sender, const QString &color, QWidget *parent) : QWidget(parent) { + init(); + auto body = event.content().body().trimmed().toHtmlEscaped(); auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); @@ -114,34 +187,45 @@ TimelineItem::TimelineItem(const events::MessageEvent &event, bool w body.replace(URL_REGEX, URL_HTML); - if (with_sender) - generateBody(TimelineViewManager::displayName(event.sender()), color, body); - else + if (with_sender) { + auto displayName = TimelineViewManager::displayName(event.sender()); + generateBody(displayName, color, body); + + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(0); + + AvatarProvider::resolve(event.sender(), this); + } else { generateBody(body); - setupLayout(); + setupSimpleLayout(); + + mainLayout_->addWidget(body_); + mainLayout_->setMargin(0); + mainLayout_->setSpacing(2); + } } +// Only the body is displayed. void TimelineItem::generateBody(const QString &body) { - content_label_ = new QLabel(this); - content_label_->setWordWrap(true); - content_label_->setAlignment(Qt::AlignTop); - content_label_->setStyleSheet("margin: 0;"); - QString content( - "" - "" - "" - " " - " %1" - " " - "" - ""); - content_label_->setText(content.arg(replaceEmoji(body))); - content_label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - content_label_->setOpenExternalLinks(true); + QString content("%1"); + + body_ = new QLabel(this); + body_->setWordWrap(true); + body_->setFont(bodyFont_); + body_->setText(content.arg(replaceEmoji(body))); + body_->setAlignment(Qt::AlignTop); + + body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + body_->setOpenExternalLinks(true); } +// The username/timestamp is displayed along with the message body. void TimelineItem::generateBody(const QString &userid, const QString &color, const QString &body) { auto sender = userid; @@ -150,64 +234,35 @@ void TimelineItem::generateBody(const QString &userid, const QString &color, con if (userid.split(":")[0].split("@").size() > 1) sender = userid.split(":")[0].split("@")[1]; - content_label_ = new QLabel(this); - content_label_->setWordWrap(true); - content_label_->setAlignment(Qt::AlignTop); - content_label_->setStyleSheet("margin: 0;"); - QString content( - "" - "" - "" - " " - " %2" - " " - " " - " %3" - " " - "" - ""); - content_label_->setText(content.arg(color).arg(sender).arg(replaceEmoji(body))); - content_label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - content_label_->setOpenExternalLinks(true); + QString userContent(" %2 "); + QString bodyContent(" %1 "); + + userName_ = new QLabel(this); + userName_->setFont(usernameFont_); + userName_->setText(userContent.arg(color).arg(sender)); + userName_->setAlignment(Qt::AlignTop); + + if (body.isEmpty()) + return; + + body_ = new QLabel(this); + body_->setFont(bodyFont_); + body_->setWordWrap(true); + body_->setAlignment(Qt::AlignTop); + body_->setText(bodyContent.arg(replaceEmoji(body))); + body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + body_->setOpenExternalLinks(true); } void TimelineItem::generateTimestamp(const QDateTime &time) { - auto local_time = time.toString("HH:mm"); + QString msg(" %1 "); - time_label_ = new QLabel(this); - QString msg( - "" - "" - "" - " " - " %1" - " " - "" - ""); - time_label_->setText(msg.arg(local_time)); - time_label_->setStyleSheet("margin-left: 7px; margin-right: 7px; margin-top: 0;"); - time_label_->setAlignment(Qt::AlignTop); -} - -void TimelineItem::setupLayout() -{ - if (time_label_ == nullptr) { - qWarning() << "TimelineItem: Time label is not initialized"; - return; - } - - if (content_label_ == nullptr) { - qWarning() << "TimelineItem: Content label is not initialized"; - return; - } - - top_layout_ = new QHBoxLayout(); - top_layout_->setMargin(0); - top_layout_->addWidget(time_label_); - top_layout_->addWidget(content_label_, 1); - - setLayout(top_layout_); + timestamp_ = new QLabel(this); + timestamp_->setFont(timestampFont_); + timestamp_->setText(msg.arg(time.toString("HH:mm"))); + timestamp_->setAlignment(Qt::AlignTop); + timestamp_->setStyleSheet("margin-top: 2px;"); } QString TimelineItem::replaceEmoji(const QString &body) @@ -227,6 +282,46 @@ QString TimelineItem::replaceEmoji(const QString &body) return fmtBody; } +void TimelineItem::setupAvatarLayout(const QString &userName) +{ + topLayout_->setContentsMargins(7, 6, 0, 0); + + userAvatar_ = new Avatar(this); + userAvatar_->setLetter(QChar(userName[0]).toUpper()); + userAvatar_->setBackgroundColor(QColor("#eee")); + userAvatar_->setTextColor(QColor("black")); + userAvatar_->setSize(32); + + // TODO: The provided user name should be a UserId class + if (userName[0] == '@' && userName.size() > 1) + userAvatar_->setLetter(QChar(userName[1]).toUpper()); + + sideLayout_->addWidget(userAvatar_); + sideLayout_->addStretch(1); + sideLayout_->setMargin(0); + sideLayout_->setSpacing(0); + + headerLayout_->addWidget(userName_); + headerLayout_->addWidget(timestamp_, 1); + headerLayout_->setMargin(0); +} + +void TimelineItem::setupSimpleLayout() +{ + sideLayout_->addWidget(timestamp_); + sideLayout_->addStretch(1); + + topLayout_->setContentsMargins(9, 0, 0, 0); +} + +void TimelineItem::setUserAvatar(const QImage &avatar) +{ + if (userAvatar_ == nullptr) + return; + + userAvatar_->setImage(avatar); +} + TimelineItem::~TimelineItem() { } diff --git a/src/main.cc b/src/main.cc index e6d4c4e7..bf165cab 100644 --- a/src/main.cc +++ b/src/main.cc @@ -36,9 +36,7 @@ int main(int argc, char *argv[]) QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Regular.ttf"); QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Italic.ttf"); QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Bold.ttf"); - QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-BoldItalic.ttf"); QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Semibold.ttf"); - QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-SemiboldItalic.ttf"); QFontDatabase::addApplicationFont(":/fonts/fonts/EmojiOne/emojione-android.ttf"); app.setWindowIcon(QIcon(":/logos/nheko.png"));