From b21942a3e3db3e425155c58483a99bc2789de241 Mon Sep 17 00:00:00 2001 From: Konstantinos Sideris Date: Tue, 28 Nov 2017 02:01:37 +0200 Subject: [PATCH] Add read support for m.file messages (#24) --- CMakeLists.txt | 2 + include/FileItem.h | 97 ++++++++ include/MatrixClient.h | 2 + include/TimelineItem.h | 6 + include/TimelineView.h | 3 + resources/icons/ui/arrow-pointing-down.png | Bin 0 -> 556 bytes resources/icons/ui/arrow-pointing-down@2x.png | Bin 0 -> 841 bytes resources/res.qrc | 2 + resources/styles/nheko-dark.qss | 6 + resources/styles/nheko.qss | 6 + resources/styles/system.qss | 6 + src/FileItem.cc | 220 ++++++++++++++++++ src/MatrixClient.cc | 26 +++ src/TimelineItem.cc | 41 ++++ src/TimelineView.cc | 38 +++ src/events/messages/File.cc | 6 +- 16 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 include/FileItem.h create mode 100644 resources/icons/ui/arrow-pointing-down.png create mode 100644 resources/icons/ui/arrow-pointing-down@2x.png create mode 100644 src/FileItem.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index a5a0e28a..81dc2ca5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,6 +149,7 @@ set(SRC_FILES src/EmojiPanel.cc src/EmojiPickButton.cc src/EmojiProvider.cc + src/FileItem.cc src/ImageItem.cc src/ImageOverlayDialog.cc src/InputValidator.cc @@ -243,6 +244,7 @@ qt5_wrap_cpp(MOC_HEADERS include/EmojiPanel.h include/EmojiPickButton.h include/ui/FloatingButton.h + include/FileItem.h include/ImageItem.h include/ImageOverlayDialog.h include/JoinRoomDialog.h diff --git a/include/FileItem.h b/include/FileItem.h new file mode 100644 index 00000000..1c47689c --- /dev/null +++ b/include/FileItem.h @@ -0,0 +1,97 @@ +/* + * 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 + +#include "File.h" +#include "MatrixClient.h" +#include "MessageEvent.h" + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +constexpr int MaxWidth = 400; +constexpr int Height = 70; +constexpr int IconRadius = 22; +constexpr int IconDiameter = IconRadius * 2; +constexpr int HorizontalPadding = 12; +constexpr int TextPadding = 15; +constexpr int DownloadIconRadius = IconRadius - 4; + +constexpr double VerticalPadding = Height - 2 * IconRadius; +constexpr double IconYCenter = Height / 2; +constexpr double IconXCenter = HorizontalPadding + IconRadius; + +class FileItem : public QWidget +{ + Q_OBJECT + + Q_PROPERTY(QColor textColor WRITE setTextColor READ textColor) + Q_PROPERTY(QColor iconColor WRITE setIconColor READ iconColor) + Q_PROPERTY(QColor backgroundColor WRITE setBackgroundColor READ backgroundColor) + +public: + FileItem(QSharedPointer client, + const events::MessageEvent &event, + QWidget *parent = nullptr); + + FileItem(QSharedPointer client, + const QString &url, + const QString &filename, + QWidget *parent = nullptr); + + QSize sizeHint() const override; + + void setTextColor(const QColor &color) { textColor_ = color; } + void setIconColor(const QColor &color) { iconColor_ = color; } + void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } + + QColor textColor() const { return textColor_; } + QColor iconColor() const { return iconColor_; } + QColor backgroundColor() const { return backgroundColor_; } + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + +private slots: + void fileDownloaded(const QString &event_id, const QByteArray &data); + +private: + QString calculateFileSize(int nbytes) const; + void openUrl(); + + QUrl url_; + QString text_; + QString readableFileSize_; + QString filenameToSave_; + + events::MessageEvent event_; + QSharedPointer client_; + + QIcon icon_; + + QColor textColor_ = QColor("white"); + QColor iconColor_ = QColor("#38A3D8"); + QColor backgroundColor_ = QColor("#333"); +}; diff --git a/include/MatrixClient.h b/include/MatrixClient.h index 999fbe47..80dc9df9 100644 --- a/include/MatrixClient.h +++ b/include/MatrixClient.h @@ -53,6 +53,7 @@ public: void fetchUserAvatar(const QString &userId, const QUrl &avatarUrl); void fetchOwnAvatar(const QUrl &avatar_url); void downloadImage(const QString &event_id, const QUrl &url); + void downloadFile(const QString &event_id, const QUrl &url); void messages(const QString &room_id, const QString &from_token, int limit = 30) noexcept; void uploadImage(const QString &roomid, const QString &filename); void joinRoom(const QString &roomIdOrAlias); @@ -96,6 +97,7 @@ signals: void userAvatarRetrieved(const QString &userId, const QImage &img); void ownAvatarRetrieved(const QPixmap &img); void imageDownloaded(const QString &event_id, const QPixmap &img); + void fileDownloaded(const QString &event_id, const QByteArray &data); // Returned profile data for the user's account. void getOwnProfileResponse(const QUrl &avatar_url, const QString &display_name); diff --git a/include/TimelineItem.h b/include/TimelineItem.h index cd522308..b94acbdb 100644 --- a/include/TimelineItem.h +++ b/include/TimelineItem.h @@ -24,6 +24,7 @@ #include #include "Emote.h" +#include "File.h" #include "Image.h" #include "MessageEvent.h" #include "Notice.h" @@ -31,6 +32,7 @@ #include "Text.h" class ImageItem; +class FileItem; class Avatar; namespace events = matrix::events; @@ -64,6 +66,10 @@ public: const events::MessageEvent &e, bool with_sender, QWidget *parent); + TimelineItem(FileItem *file, + const events::MessageEvent &e, + bool with_sender, + QWidget *parent); void setUserAvatar(const QImage &pixmap); DescInfo descriptionMessage() const { return descriptionMsg_; } diff --git a/include/TimelineView.h b/include/TimelineView.h index 3f506002..e3bedff0 100644 --- a/include/TimelineView.h +++ b/include/TimelineView.h @@ -25,6 +25,7 @@ #include #include "Emote.h" +#include "File.h" #include "Image.h" #include "MessageEvent.h" #include "Notice.h" @@ -95,6 +96,8 @@ public: bool with_sender); TimelineItem *createTimelineItem(const events::MessageEvent &e, bool with_sender); + TimelineItem *createTimelineItem(const events::MessageEvent &e, + bool with_sender); // Add new events at the end of the timeline. int addEvents(const Timeline &timeline); diff --git a/resources/icons/ui/arrow-pointing-down.png b/resources/icons/ui/arrow-pointing-down.png new file mode 100644 index 0000000000000000000000000000000000000000..b198dcced458ec20cf3f545b6fe4906c158d630a GIT binary patch literal 556 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-GzZ+Rj;xUkjGiz z5m^kh={g8AI%&+V01C2~c>21sKjfAcGtyAZS^%_(;g_e2V~EG`x0ejL4jD)|JY3$a z&~=XQk?$Ov_Gahx4<3oyG&HLh1PSmz2&!TeoU==8)#6@f9p779e47@ZoHBpUdgsdv zGtbUoFyyznHicV~$?;h0O6v`-8r84)jz|eyk9~cO>2KR)(S)7<4xDeWWLILU3iIu6 z=wSX_v*F3#T0Qqxk1g#}G=6@xJ{@s#{r;)ltch%0F+$uHnd01sSYGUOC}@k$oFW{$ zU>5hg4eNMR1KE6bCNMLvb5pu{Zlxi=BqL9+%Wpo(=lhSf2NcOKnzCD{?M~~8lewL3 zciJ*e-sb4u)4AcyYmW9iE*8&H4=GPS(x-L1P+nfHmzkGcoSayYs+V7sKKq@G6i^X^r>mdKI;Vst00q9n AzW@LL literal 0 HcmV?d00001 diff --git a/resources/icons/ui/arrow-pointing-down@2x.png b/resources/icons/ui/arrow-pointing-down@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4722f3bcfd255d7073f3631e242ea305d257dd36 GIT binary patch literal 841 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_5;mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk<_u6#f+2=?)QubY7+N!aSX}0_x9#qufq-u2R`mU zx^cmRxiT?xx}x^)KjdY9NgD6+7Ny3f6GaH&G9R?^kF&Jclf3+uML zR4tz#b53>oQq}3d|IeB-A?U~Ri{hVlB#NE5^#AX>$=2&C%3A$4`g1?L8TDpz5wpgZ zBlX*wy8leek5jl|WIuOVvihMo?S`0^yai?#8=+Uvy6<6*XBdFu$+%q+V^!=Xaau^dxXicz%1oiWx&&1xLh&=?QEf z&U3T$^RWIsGeOvg;b3Ls1A(U^`i}Ao&T;2G@U>hydja=_liag3yH*@$S-$T(>(3Jf zr&^BfN$M(I%X*e$SEWU>iED50*K)-x(;ePF;b!^1gz;FVk-`dwUq8c_{r4+yoh@Lq zIcdWDm-~+GVzHjB{(jbnQ)~Xre%BT-^*N)OtN_y|J&w?4A=}$5IHi7CaE3lB&0jj% zCi?vKFP{qqcs?^&cDG1zFRPs0GNCfMMXEdTvl++NjGrr5xGNi0PH3_6Hv5#wDfT({ zPi>nzZ`s@jhrT%POoeT zJXiNwyr(r*?aln_Zwp?0K{f>E rrERK(!v>gTe~DWM4fn@dsx literal 0 HcmV?d00001 diff --git a/resources/res.qrc b/resources/res.qrc index cfe0bf2f..95de2ec9 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -24,6 +24,8 @@ icons/ui/angle-pointing-to-left@2x.png icons/ui/angle-arrow-down.png icons/ui/angle-arrow-down@2x.png + icons/ui/arrow-pointing-down.png + icons/ui/arrow-pointing-down@2x.png icons/emoji-categories/people.png icons/emoji-categories/people@2x.png diff --git a/resources/styles/nheko-dark.qss b/resources/styles/nheko-dark.qss index 0d68acfb..1a32ced3 100644 --- a/resources/styles/nheko-dark.qss +++ b/resources/styles/nheko-dark.qss @@ -22,6 +22,12 @@ FlatButton { qproperty-backgroundColor: #333; } +FileItem { + qproperty-textColor: #caccd1; + qproperty-backgroundColor: #414A59; + qproperty-iconColor: #caccd1; +} + RaisedButton { qproperty-foregroundColor: #caccd1; qproperty-backgroundColor: #333; diff --git a/resources/styles/nheko.qss b/resources/styles/nheko.qss index 4840e9b5..3e889530 100644 --- a/resources/styles/nheko.qss +++ b/resources/styles/nheko.qss @@ -21,6 +21,12 @@ FlatButton { qproperty-foregroundColor: #333; } +FileItem { + qproperty-textColor: #333; + qproperty-backgroundColor: #f2f2f2; + qproperty-iconColor: white; +} + RaisedButton { qproperty-foregroundColor: white; } diff --git a/resources/styles/system.qss b/resources/styles/system.qss index 0683a48d..bce0f059 100644 --- a/resources/styles/system.qss +++ b/resources/styles/system.qss @@ -19,6 +19,12 @@ FlatButton { qproperty-foregroundColor: palette(text); } +FileItem { + qproperty-textColor: palette(text); + qproperty-backgroundColor: palette(base); + qproperty-iconColor: palette(window); +} + RaisedButton { qproperty-foregroundColor: palette(light); } diff --git a/src/FileItem.cc b/src/FileItem.cc new file mode 100644 index 00000000..cd934783 --- /dev/null +++ b/src/FileItem.cc @@ -0,0 +1,220 @@ +/* + * 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 +#include +#include + +#include "FileItem.h" +#include "ImageOverlayDialog.h" + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +FileItem::FileItem(QSharedPointer client, + const events::MessageEvent &event, + QWidget *parent) + : QWidget(parent) + , event_{event} + , client_{client} +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + url_ = event.msgContent().url(); + text_ = event.content().body(); + readableFileSize_ = calculateFileSize(event.msgContent().info().size); + + icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); + + QList url_parts = url_.toString().split("mxc://"); + + if (url_parts.size() != 2) { + qDebug() << "Invalid format for image" << url_.toString(); + return; + } + + QString media_params = url_parts[1]; + url_ = QString("%1/_matrix/media/r0/download/%2") + .arg(client_.data()->getHomeServer().toString(), media_params); + + connect(client_.data(), &MatrixClient::fileDownloaded, this, &FileItem::fileDownloaded); +} + +FileItem::FileItem(QSharedPointer client, + const QString &url, + const QString &filename, + QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{QFileInfo(filename).fileName()} + , client_{client} +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + // TODO: calculateFileSize + /* readableFileSize_ = calculateFileSize(event.msgContent().info().size); */ + + QList url_parts = url_.toString().split("mxc://"); + + icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); + + if (url_parts.size() != 2) { + qDebug() << "Invalid format for image" << url_.toString(); + return; + } + + QString media_params = url_parts[1]; + url_ = QString("%1/_matrix/media/r0/download/%2") + .arg(client_.data()->getHomeServer().toString(), media_params); +} + +QString +FileItem::calculateFileSize(int nbytes) const +{ + if (nbytes < 1024) + return QString("%1 B").arg(nbytes); + + if (nbytes < 1024 * 1024) + return QString("%1 KB").arg(nbytes / 1024); + + return QString("%1 MB").arg(nbytes / 1024 / 1024); +} + +void +FileItem::openUrl() +{ + if (url_.toString().isEmpty()) + return; + + if (!QDesktopServices::openUrl(url_)) + qWarning() << "Could not open url" << url_.toString(); +} + +QSize +FileItem::sizeHint() const +{ + return QSize(MaxWidth, Height); +} + +void +FileItem::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) + return; + + auto point = event->pos(); + + // Click on the download icon. + if (QRect(HorizontalPadding, VerticalPadding / 2, IconDiameter, IconDiameter) + .contains(point)) { + filenameToSave_ = QFileDialog::getSaveFileName(this, tr("Save File"), text_); + + if (filenameToSave_.isEmpty()) + return; + + client_->downloadFile(event_.eventId(), url_); + } else { + openUrl(); + } +} + +void +FileItem::fileDownloaded(const QString &event_id, const QByteArray &data) +{ + if (event_id != event_.eventId()) + return; + + try { + QFile file(filenameToSave_); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(data); + file.close(); + } catch (const std::exception &ex) { + qDebug() << "Error while saving file to:" << ex.what(); + } +} + +void +FileItem::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QFont font("Open Sans"); + font.setPixelSize(12); + font.setWeight(80); + + QFontMetrics fm(font); + + int computedWidth = std::min( + fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); + + QPainterPath path; + path.addRoundedRect(QRectF(0, 0, computedWidth, Height), 10, 10); + + painter.setPen(Qt::NoPen); + painter.fillPath(path, backgroundColor_); + painter.drawPath(path); + + QPainterPath circle; + circle.addEllipse(QPoint(IconXCenter, IconYCenter), IconRadius, IconRadius); + + painter.setPen(Qt::NoPen); + painter.fillPath(circle, iconColor_); + painter.drawPath(circle); + + icon_.paint(&painter, + QRect(IconXCenter - DownloadIconRadius / 2, + IconYCenter - DownloadIconRadius / 2, + DownloadIconRadius, + DownloadIconRadius), + Qt::AlignCenter, + QIcon::Normal); + + const int textStartX = HorizontalPadding + 2 * IconRadius + TextPadding; + const int textStartY = VerticalPadding + fm.ascent() / 2; + + // Draw the filename. + QString elidedText = + fm.elidedText(text_, + Qt::ElideRight, + computedWidth - HorizontalPadding * 2 - TextPadding - 2 * IconRadius); + + painter.setFont(font); + painter.setPen(QPen(textColor_)); + painter.drawText(QPoint(textStartX, textStartY), elidedText); + + // Draw the filesize. + font.setWeight(50); + painter.setFont(font); + painter.setPen(QPen(textColor_)); + painter.drawText(QPoint(textStartX, textStartY + 1.5 * fm.ascent()), readableFileSize_); +} diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc index dcf241a6..a171cd09 100644 --- a/src/MatrixClient.cc +++ b/src/MatrixClient.cc @@ -586,6 +586,32 @@ MatrixClient::downloadImage(const QString &event_id, const QUrl &url) }); } +void +MatrixClient::downloadFile(const QString &event_id, const QUrl &url) +{ + QNetworkRequest fileRequest(url); + + auto reply = get(fileRequest); + connect(reply, &QNetworkReply::finished, this, [this, reply, event_id]() { + reply->deleteLater(); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (status == 0 || status >= 400) { + // TODO: Handle error + qWarning() << reply->errorString(); + return; + } + + auto data = reply->readAll(); + + if (data.size() == 0) + return; + + emit fileDownloaded(event_id, data); + }); +} + void MatrixClient::fetchOwnAvatar(const QUrl &avatar_url) { diff --git a/src/TimelineItem.cc b/src/TimelineItem.cc index 03d375c3..b57f5118 100644 --- a/src/TimelineItem.cc +++ b/src/TimelineItem.cc @@ -24,6 +24,7 @@ #include "Avatar.h" #include "AvatarProvider.h" #include "Config.h" +#include "FileItem.h" #include "ImageItem.h" #include "Sync.h" #include "TimelineItem.h" @@ -186,6 +187,46 @@ TimelineItem::TimelineItem(ImageItem *image, mainLayout_->addLayout(imageLayout); } +TimelineItem::TimelineItem(FileItem *file, + const events::MessageEvent &event, + bool with_sender, + QWidget *parent) + : QWidget(parent) +{ + init(); + + event_id_ = event.eventId(); + + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); + auto displayName = TimelineViewManager::displayName(event.sender()); + + QSettings settings; + descriptionMsg_ = {event.sender() == settings.value("auth/user_id") ? "You" : displayName, + event.sender(), + " sent a file", + descriptiveTime(QDateTime::fromMSecsSinceEpoch(event.timestamp()))}; + + generateTimestamp(timestamp); + + auto fileLayout = new QHBoxLayout(); + fileLayout->setContentsMargins(0, 5, 0, 0); + fileLayout->addWidget(file); + fileLayout->addStretch(1); + + if (with_sender) { + generateBody(displayName, ""); + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(event.sender(), this); + } else { + setupSimpleLayout(); + } + + mainLayout_->addLayout(fileLayout); +} + /* * Used to display remote notice messages. */ diff --git a/src/TimelineView.cc b/src/TimelineView.cc index ed046fe1..bdc59af3 100644 --- a/src/TimelineView.cc +++ b/src/TimelineView.cc @@ -21,6 +21,7 @@ #include #include +#include "FileItem.h" #include "FloatingButton.h" #include "ImageItem.h" #include "RoomMessages.h" @@ -331,6 +332,34 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire updateLastSender(emote.sender(), direction); return createTimelineItem(emote, with_sender); + } else if (msg_type == events::MessageEventType::File) { + events::MessageEvent file; + + try { + file.deserialize(event); + } catch (const DeserializationException &e) { + qWarning() << e.what() << event; + return nullptr; + } + + if (isDuplicate(file.eventId())) + return nullptr; + + eventIds_[file.eventId()] = true; + + QString txnid = file.unsignedData().transactionId(); + + if (!txnid.isEmpty() && + isPendingMessage(txnid, file.sender(), local_user_)) { + removePendingMessage(txnid); + return nullptr; + } + + auto withSender = isSenderRendered(file.sender(), direction); + + updateLastSender(file.sender(), direction); + + return createTimelineItem(file, withSender); } else if (msg_type == events::MessageEventType::Unknown) { // TODO Handle redacted messages. // Silenced for now. @@ -469,6 +498,15 @@ TimelineView::createTimelineItem(const events::MessageEvent &event, return item; } +TimelineItem * +TimelineView::createTimelineItem(const events::MessageEvent &event, bool withSender) +{ + auto file = new FileItem(client_, event); + auto item = new TimelineItem(file, event, withSender, scroll_widget_); + + return item; +} + TimelineItem * TimelineView::createTimelineItem(const events::MessageEvent &event, bool with_sender) { diff --git a/src/events/messages/File.cc b/src/events/messages/File.cc index 9945f1f8..28bce441 100644 --- a/src/events/messages/File.cc +++ b/src/events/messages/File.cc @@ -25,13 +25,11 @@ File::deserialize(const QJsonObject &object) if (!object.contains("url")) throw DeserializationException("messages::File url key is missing"); - if (!object.contains("filename")) - throw DeserializationException("messages::File filename key is missing"); - if (object.value("msgtype") != "m.file") throw DeserializationException("invalid msgtype for file"); - url_ = object.value("url").toString(); + url_ = object.value("url").toString(); + filename_ = object.value("filename").toString(); if (object.contains("info")) { auto file_info = object.value("info").toObject();