diff --git a/CMakeLists.txt b/CMakeLists.txt index 926448df..5cbdc797 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,7 +141,7 @@ set(SRC_FILES src/ui/Avatar.cc src/ui/Badge.cc - src/ui/CircularProgress.cc + src/ui/LoadingIndicator.cc src/ui/FlatButton.cc src/ui/OverlayModal.cc src/ui/ScrollBar.cc @@ -217,7 +217,7 @@ qt5_wrap_cpp(MOC_HEADERS include/ui/Avatar.h include/ui/Badge.h - include/ui/CircularProgress.h + include/ui/LoadingIndicator.h include/ui/FlatButton.h include/ui/OverlayWidget.h include/ui/ScrollBar.h diff --git a/include/ImageItem.h b/include/ImageItem.h index e37e04b2..20e0772d 100644 --- a/include/ImageItem.h +++ b/include/ImageItem.h @@ -36,6 +36,11 @@ public: const events::MessageEvent &event, QWidget *parent = nullptr); + ImageItem(QSharedPointer client, + const QString &url, + const QString &filename, + QWidget *parent = nullptr); + void setImage(const QPixmap &image); QSize sizeHint() const override; diff --git a/include/LoginPage.h b/include/LoginPage.h index db852e2c..5caa3f1e 100644 --- a/include/LoginPage.h +++ b/include/LoginPage.h @@ -23,8 +23,8 @@ #include #include -#include "CircularProgress.h" #include "FlatButton.h" +#include "LoadingIndicator.h" #include "MatrixClient.h" #include "OverlayModal.h" #include "RaisedButton.h" @@ -79,7 +79,7 @@ private: QHBoxLayout *serverLayout_; QHBoxLayout *matrixidLayout_; - CircularProgress *spinner_; + LoadingIndicator *spinner_; QLabel *errorIcon_; QString inferredServerAddress_; diff --git a/include/MainWindow.h b/include/MainWindow.h index 6f1d6e3e..de535d35 100644 --- a/include/MainWindow.h +++ b/include/MainWindow.h @@ -21,7 +21,7 @@ #include #include "ChatPage.h" -#include "CircularProgress.h" +#include "LoadingIndicator.h" #include "LoginPage.h" #include "MatrixClient.h" #include "OverlayModal.h" @@ -85,7 +85,7 @@ private: // Used to hide undefined states between page transitions. OverlayModal *progress_modal_; - CircularProgress *spinner_; + LoadingIndicator *spinner_; // Matrix Client API provider. QSharedPointer client_; diff --git a/include/MatrixClient.h b/include/MatrixClient.h index 58b24f9b..8d6c60a7 100644 --- a/include/MatrixClient.h +++ b/include/MatrixClient.h @@ -39,7 +39,8 @@ public: void sync() noexcept; void sendRoomMessage(matrix::events::MessageEventType ty, const QString &roomid, - const QString &msg) noexcept; + const QString &msg, + const QString &url = "") noexcept; void login(const QString &username, const QString &password) noexcept; void registerUser(const QString &username, const QString &password, @@ -50,6 +51,7 @@ public: 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, int limit = 20) noexcept; + void uploadImage(const QString &roomid, const QString &filename); inline QUrl getHomeServer(); inline int transactionId(); @@ -77,6 +79,7 @@ signals: const QString &homeserver, const QString &token); void versionSuccess(); + void imageUploaded(const QString &roomid, const QString &filename, const QString &url); void roomAvatarRetrieved(const QString &roomid, const QPixmap &img); void userAvatarRetrieved(const QString &userId, const QImage &img); @@ -102,6 +105,7 @@ private: GetProfile, Image, InitialSync, + ImageUpload, Login, Logout, Messages, @@ -118,6 +122,7 @@ private: void onGetOwnProfileResponse(QNetworkReply *reply); void onImageResponse(QNetworkReply *reply); void onInitialSyncResponse(QNetworkReply *reply); + void onImageUploadResponse(QNetworkReply *reply); void onLoginResponse(QNetworkReply *reply); void onLogoutResponse(QNetworkReply *reply); void onMessagesResponse(QNetworkReply *reply); @@ -129,7 +134,10 @@ private: void onVersionsResponse(QNetworkReply *reply); // Client API prefix. - QString api_url_; + QString clientApiUrl_; + + // Media API prefix. + QString mediaApiUrl_; // The Matrix server used for communication. QUrl server_; diff --git a/include/TextInputWidget.h b/include/TextInputWidget.h index 73c2a603..732f4f61 100644 --- a/include/TextInputWidget.h +++ b/include/TextInputWidget.h @@ -24,6 +24,10 @@ #include "EmojiPickButton.h" #include "FlatButton.h" +#include "Image.h" +#include "LoadingIndicator.h" + +namespace msgs = matrix::events::messages; static const QString EMOTE_COMMAND("/me "); @@ -48,6 +52,8 @@ public: public slots: void onSendButtonClicked(); + void openFileSelection(); + void hideUploadSpinner(); inline void focusLineEdit(); private slots: @@ -56,16 +62,20 @@ private slots: signals: void sendTextMessage(QString msg); void sendEmoteMessage(QString msg); + void uploadImage(QString filename); private: + void showUploadSpinner(); QString parseEmoteCommand(const QString &cmd); - QHBoxLayout *top_layout_; + QHBoxLayout *topLayout_; FilteredTextEdit *input_; - FlatButton *send_file_button_; - FlatButton *send_message_button_; - EmojiPickButton *emoji_button_; + LoadingIndicator *spinner_; + + FlatButton *sendFileBtn_; + FlatButton *sendMessageBtn_; + EmojiPickButton *emojiBtn_; }; inline void diff --git a/include/TimelineItem.h b/include/TimelineItem.h index edc15dab..0a0538f9 100644 --- a/include/TimelineItem.h +++ b/include/TimelineItem.h @@ -50,11 +50,14 @@ public: QWidget *parent = 0); // For local messages. + // m.text & m.emote TimelineItem(events::MessageEventType ty, const QString &userid, QString body, bool withSender, QWidget *parent = 0); + // m.image + TimelineItem(ImageItem *item, const QString &userid, bool withSender, QWidget *parent = 0); TimelineItem(ImageItem *img, const events::MessageEvent &e, diff --git a/include/TimelineView.h b/include/TimelineView.h index 3ecf8ba7..61283fa2 100644 --- a/include/TimelineView.h +++ b/include/TimelineView.h @@ -85,6 +85,7 @@ public: // Add new events at the end of the timeline. int addEvents(const Timeline &timeline); void addUserMessage(matrix::events::MessageEventType ty, const QString &msg, int txn_id); + void addUserMessage(const QString &url, const QString &filename, int txn_id); void updatePendingMessage(int txn_id, QString event_id); void scrollDown(); @@ -108,11 +109,11 @@ 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); - template - bool isPendingMessage(const events::MessageEvent &e, const QString &userid); - - template - void removePendingMessage(const events::MessageEvent &e); + bool isPendingMessage(const QString &eventid, + const QString &body, + const QString &sender, + const QString &userid); + void removePendingMessage(const QString &eventid, const QString &body); inline bool isDuplicate(const QString &event_id); @@ -159,32 +160,3 @@ TimelineView::isDuplicate(const QString &event_id) { return eventIds_.contains(event_id); } - -template -bool -TimelineView::isPendingMessage(const events::MessageEvent &e, const QString &local_userid) -{ - if (e.sender() != local_userid) - return false; - - for (const auto &msg : pending_msgs_) { - if (msg.event_id == e.eventId() || msg.body == e.content().body()) - return true; - } - - return false; -} - -template -void -TimelineView::removePendingMessage(const events::MessageEvent &e) -{ - for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); it++) { - int index = std::distance(pending_msgs_.begin(), it); - - if (it->event_id == e.eventId() || it->body == e.content().body()) { - pending_msgs_.removeAt(index); - break; - } - } -} diff --git a/include/TimelineViewManager.h b/include/TimelineViewManager.h index d3ca198e..14f47928 100644 --- a/include/TimelineViewManager.h +++ b/include/TimelineViewManager.h @@ -56,6 +56,7 @@ public slots: void setHistoryView(const QString &room_id); void sendTextMessage(const QString &msg); void sendEmoteMessage(const QString &msg); + void sendImageMessage(const QString &roomid, const QString &filename, const QString &url); private slots: void messageSent(const QString &eventid, const QString &roomid, int txnid); diff --git a/include/ui/CircularProgress.h b/include/ui/CircularProgress.h deleted file mode 100644 index 291cce1c..00000000 --- a/include/ui/CircularProgress.h +++ /dev/null @@ -1,120 +0,0 @@ -#pragma once - -#include -#include - -#include "Theme.h" - -class CircularProgressDelegate; - -class CircularProgress : public QProgressBar -{ - Q_OBJECT - - Q_PROPERTY(qreal lineWidth WRITE setLineWidth READ lineWidth) - Q_PROPERTY(qreal size WRITE setSize READ size) - Q_PROPERTY(QColor color WRITE setColor READ color) - -public: - explicit CircularProgress(QWidget *parent = nullptr); - ~CircularProgress(); - - void setProgressType(ui::ProgressType type); - void setLineWidth(qreal width); - void setSize(int size); - void setColor(const QColor &color); - - ui::ProgressType progressType() const; - qreal lineWidth() const; - int size() const; - QColor color() const; - - QSize sizeHint() const override; - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - CircularProgressDelegate *delegate_; - - ui::ProgressType progress_type_; - - QColor color_; - - // Circle width. - qreal width_; - - // Circle radius. - int size_; - - // Animation duration. - int duration_; -}; - -class CircularProgressDelegate : public QObject -{ - Q_OBJECT - - Q_PROPERTY(qreal dashOffset WRITE setDashOffset READ dashOffset) - Q_PROPERTY(qreal dashLength WRITE setDashLength READ dashLength) - Q_PROPERTY(int angle WRITE setAngle READ angle) - -public: - explicit CircularProgressDelegate(CircularProgress *parent); - ~CircularProgressDelegate(); - - inline void setDashOffset(qreal offset); - inline void setDashLength(qreal length); - inline void setAngle(int angle); - - inline qreal dashOffset() const; - inline qreal dashLength() const; - inline int angle() const; - -private: - CircularProgress *const progress_; - - qreal dash_offset_; - qreal dash_length_; - - int angle_; -}; - -inline void -CircularProgressDelegate::setDashOffset(qreal offset) -{ - dash_offset_ = offset; - progress_->update(); -} - -inline void -CircularProgressDelegate::setDashLength(qreal length) -{ - dash_length_ = length; - progress_->update(); -} - -inline void -CircularProgressDelegate::setAngle(int angle) -{ - angle_ = angle; - progress_->update(); -} - -inline qreal -CircularProgressDelegate::dashOffset() const -{ - return dash_offset_; -} - -inline qreal -CircularProgressDelegate::dashLength() const -{ - return dash_length_; -} - -inline int -CircularProgressDelegate::angle() const -{ - return angle_; -} diff --git a/include/ui/LoadingIndicator.h b/include/ui/LoadingIndicator.h new file mode 100644 index 00000000..2641955a --- /dev/null +++ b/include/ui/LoadingIndicator.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +class LoadingIndicator : public QWidget +{ + Q_OBJECT + +public: + LoadingIndicator(QWidget *parent = 0); + virtual ~LoadingIndicator(); + + void paintEvent(QPaintEvent *e); + + void start(); + void stop(); + + QColor color() + { + return color_; + } + void setColor(QColor color) + { + color_ = color; + } + + int interval() + { + return interval_; + } + void setInterval(int interval) + { + interval_ = interval; + } + +private slots: + void onTimeout(); + +private: + int interval_; + int angle_; + + QColor color_; + QTimer *timer_; +}; diff --git a/src/ChatPage.cc b/src/ChatPage.cc index d393a65d..6bfbf400 100644 --- a/src/ChatPage.cc +++ b/src/ChatPage.cc @@ -153,6 +153,18 @@ ChatPage::ChatPage(QSharedPointer client, QWidget *parent) view_manager_, SLOT(sendEmoteMessage(const QString &))); + connect(text_input_, &TextInputWidget::uploadImage, this, [=](QString filename) { + client_->uploadImage(current_room_, filename); + }); + + connect(client_.data(), + &MatrixClient::imageUploaded, + this, + [=](QString roomid, QString filename, QString url) { + text_input_->hideUploadSpinner(); + view_manager_->sendImageMessage(roomid, filename, url); + }); + connect(client_.data(), SIGNAL(roomAvatarRetrieved(const QString &, const QPixmap &)), this, diff --git a/src/ImageItem.cc b/src/ImageItem.cc index d4244317..e84a2a9f 100644 --- a/src/ImageItem.cc +++ b/src/ImageItem.cc @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -61,6 +62,33 @@ ImageItem::ImageItem(QSharedPointer client, SLOT(imageDownloaded(const QString &, const QPixmap &))); } +ImageItem::ImageItem(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); + + 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); + + setImage(QPixmap(filename)); +} + void ImageItem::imageDownloaded(const QString &event_id, const QPixmap &img) { diff --git a/src/LoginPage.cc b/src/LoginPage.cc index b2614c0f..87485878 100644 --- a/src/LoginPage.cc +++ b/src/LoginPage.cc @@ -77,10 +77,10 @@ LoginPage::LoginPage(QSharedPointer client, QWidget *parent) matrixid_input_->setBackgroundColor("#f9f9f9"); matrixid_input_->setPlaceholderText(tr("e.g @joe:matrix.org")); - spinner_ = new CircularProgress(this); + spinner_ = new LoadingIndicator(this); spinner_->setColor("#acc7dc"); - spinner_->setSize(32); - spinner_->setMaximumWidth(spinner_->width()); + spinner_->setFixedHeight(40); + spinner_->setFixedWidth(40); spinner_->hide(); errorIcon_ = new QLabel(this); @@ -192,11 +192,11 @@ LoginPage::onMatrixIdEntered() if (serverInput_->isVisible()) { matrixidLayout_->removeWidget(spinner_); serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); - spinner_->show(); + spinner_->start(); } else { serverLayout_->removeWidget(spinner_); matrixidLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); - spinner_->show(); + spinner_->start(); } inferredServerAddress_ = homeServer; @@ -216,7 +216,7 @@ LoginPage::onServerAddressEntered() serverLayout_->removeWidget(errorIcon_); errorIcon_->hide(); serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); - spinner_->show(); + spinner_->start(); } void @@ -240,7 +240,7 @@ LoginPage::versionError(QString error) error_label_->setText(error); serverInput_->show(); - spinner_->hide(); + spinner_->stop(); serverLayout_->removeWidget(spinner_); serverLayout_->addWidget(errorIcon_, 0, Qt::AlignVCenter | Qt::AlignRight); errorIcon_->show(); @@ -252,7 +252,7 @@ LoginPage::versionSuccess() { serverLayout_->removeWidget(spinner_); matrixidLayout_->removeWidget(spinner_); - spinner_->hide(); + spinner_->stop(); if (serverInput_->isVisible()) serverInput_->hide(); @@ -282,7 +282,7 @@ LoginPage::reset() password_input_->clear(); serverInput_->clear(); - spinner_->hide(); + spinner_->stop(); errorIcon_->hide(); serverLayout_->removeWidget(spinner_); serverLayout_->removeWidget(errorIcon_); diff --git a/src/MainWindow.cc b/src/MainWindow.cc index d53b59cd..c76657d7 100644 --- a/src/MainWindow.cc +++ b/src/MainWindow.cc @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#include "MainWindow.h" #include "Config.h" +#include "MainWindow.h" #include #include @@ -135,6 +135,8 @@ MainWindow::removeOverlayProgressBar() if (spinner_ != nullptr) spinner_->deleteLater(); + spinner_->stop(); + progress_modal_ = nullptr; spinner_ = nullptr; }); @@ -163,9 +165,11 @@ MainWindow::showChatPage(QString userid, QString homeserver, QString token) } if (spinner_ == nullptr) { - spinner_ = new CircularProgress(this); + spinner_ = new LoadingIndicator(this); spinner_->setColor("#acc7dc"); - spinner_->setSize(100); + spinner_->setFixedHeight(120); + spinner_->setFixedWidth(120); + spinner_->start(); } if (progress_modal_ == nullptr) { diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc index e053642d..2e4f7c47 100644 --- a/src/MatrixClient.cc +++ b/src/MatrixClient.cc @@ -16,6 +16,8 @@ */ #include +#include +#include #include #include #include @@ -34,11 +36,10 @@ MatrixClient::MatrixClient(QString server, QObject *parent) : QNetworkAccessManager(parent) + , clientApiUrl_{ "/_matrix/client/r0" } + , mediaApiUrl_{ "/_matrix/media/r0" } + , server_{ "https://" + server } { - server_ = "https://" + server; - api_url_ = "/_matrix/client/r0"; - token_ = ""; - QSettings settings; txn_id_ = settings.value("client/transaction_id", 1).toInt(); @@ -236,6 +237,42 @@ MatrixClient::onInitialSyncResponse(QNetworkReply *reply) emit initialSyncCompleted(response); } +void +MatrixClient::onImageUploadResponse(QNetworkReply *reply) +{ + reply->deleteLater(); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (status == 0 || status >= 400) { + emit syncFailed(reply->errorString()); + return; + } + + auto data = reply->readAll(); + + if (data.isEmpty()) + return; + + auto json = QJsonDocument::fromJson(data); + + if (!json.isObject()) { + qDebug() << "Media upload: Response is not a json object."; + return; + } + + QJsonObject object = json.object(); + if (!object.contains("content_uri")) { + qDebug() << "Media upload: Missing content_uri key"; + qDebug() << object; + return; + } + + emit imageUploaded(reply->property("room_id").toString(), + reply->property("filename").toString(), + object.value("content_uri").toString()); +} + void MatrixClient::onSyncResponse(QNetworkReply *reply) { @@ -450,6 +487,9 @@ MatrixClient::onResponse(QNetworkReply *reply) case Endpoint::InitialSync: onInitialSyncResponse(reply); break; + case Endpoint::ImageUpload: + onImageUploadResponse(reply); + break; case Endpoint::Sync: onSyncResponse(reply); break; @@ -477,7 +517,7 @@ void MatrixClient::login(const QString &username, const QString &password) noexcept { QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/login"); + endpoint.setPath(clientApiUrl_ + "/login"); QNetworkRequest request(endpoint); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -495,7 +535,7 @@ MatrixClient::logout() noexcept query.addQueryItem("access_token", token_); QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/logout"); + endpoint.setPath(clientApiUrl_ + "/logout"); endpoint.setQuery(query); QNetworkRequest request(endpoint); @@ -515,7 +555,7 @@ MatrixClient::registerUser(const QString &user, const QString &pass, const QStri query.addQueryItem("kind", "user"); QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/register"); + endpoint.setPath(clientApiUrl_ + "/register"); endpoint.setQuery(query); QNetworkRequest request(QString(endpoint.toEncoded())); @@ -549,7 +589,7 @@ MatrixClient::sync() noexcept query.addQueryItem("since", next_batch_); QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/sync"); + endpoint.setPath(clientApiUrl_ + "/sync"); endpoint.setQuery(query); QNetworkRequest request(QString(endpoint.toEncoded())); @@ -561,32 +601,35 @@ MatrixClient::sync() noexcept void MatrixClient::sendRoomMessage(matrix::events::MessageEventType ty, const QString &roomid, - const QString &msg) noexcept + const QString &msg, + const QString &url) noexcept { QUrlQuery query; query.addQueryItem("access_token", token_); QUrl endpoint(server_); - endpoint.setPath(api_url_ + + endpoint.setPath(clientApiUrl_ + QString("/rooms/%1/send/m.room.message/%2").arg(roomid).arg(txn_id_)); endpoint.setQuery(query); QString msgType(""); + QJsonObject body; switch (ty) { case matrix::events::MessageEventType::Text: - msgType = "m.text"; + body = { { "msgtype", "m.text" }, { "body", msg } }; break; case matrix::events::MessageEventType::Emote: - msgType = "m.emote"; + body = { { "msgtype", "m.emote" }, { "body", msg } }; + break; + case matrix::events::MessageEventType::Image: + body = { { "msgtype", "m.image" }, { "body", msg }, { "url", url } }; break; default: - msgType = "m.text"; - break; + qDebug() << "SendRoomMessage: Unknown message type for" << msg; + return; } - QJsonObject body{ { "msgtype", msgType }, { "body", msg } }; - QNetworkRequest request(QString(endpoint.toEncoded())); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -617,7 +660,7 @@ MatrixClient::initialSync() noexcept query.addQueryItem("access_token", token_); QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/sync"); + endpoint.setPath(clientApiUrl_ + "/sync"); endpoint.setQuery(query); QNetworkRequest request(QString(endpoint.toEncoded())); @@ -650,7 +693,7 @@ MatrixClient::getOwnProfile() noexcept query.addQueryItem("access_token", token_); QUrl endpoint(server_); - endpoint.setPath(api_url_ + "/profile/" + userid); + endpoint.setPath(clientApiUrl_ + "/profile/" + userid); endpoint.setQuery(query); QNetworkRequest request(QString(endpoint.toEncoded())); @@ -762,7 +805,7 @@ MatrixClient::messages(const QString &room_id, const QString &from_token, int li query.addQueryItem("limit", QString::number(limit)); QUrl endpoint(server_); - endpoint.setPath(api_url_ + QString("/rooms/%1/messages").arg(room_id)); + endpoint.setPath(clientApiUrl_ + QString("/rooms/%1/messages").arg(room_id)); endpoint.setQuery(query); QNetworkRequest request(QString(endpoint.toEncoded())); @@ -771,3 +814,31 @@ MatrixClient::messages(const QString &room_id, const QString &from_token, int li reply->setProperty("endpoint", static_cast(Endpoint::Messages)); reply->setProperty("room_id", room_id); } + +void +MatrixClient::uploadImage(const QString &roomid, const QString &filename) +{ + QUrlQuery query; + query.addQueryItem("access_token", token_); + + QUrl endpoint(server_); + endpoint.setPath(mediaApiUrl_ + "/upload"); + endpoint.setQuery(query); + + QFile file(filename); + if (!file.open(QIODevice::ReadWrite)) { + qDebug() << "Error while reading" << filename; + return; + } + + auto imgFormat = QString(QImageReader::imageFormat(filename)); + + QNetworkRequest request(QString(endpoint.toEncoded())); + request.setHeader(QNetworkRequest::ContentLengthHeader, file.size()); + request.setHeader(QNetworkRequest::ContentTypeHeader, QString("image/%1").arg(imgFormat)); + + QNetworkReply *reply = post(request, file.readAll()); + reply->setProperty("endpoint", static_cast(Endpoint::ImageUpload)); + reply->setProperty("room_id", roomid); + reply->setProperty("filename", filename); +} diff --git a/src/TextInputWidget.cc b/src/TextInputWidget.cc index bd74186e..ce208feb 100644 --- a/src/TextInputWidget.cc +++ b/src/TextInputWidget.cc @@ -17,6 +17,8 @@ #include #include +#include +#include #include #include @@ -47,17 +49,23 @@ TextInputWidget::TextInputWidget(QWidget *parent) setCursor(Qt::ArrowCursor); setStyleSheet("background-color: #f8fbfe; height: 45px;"); - top_layout_ = new QHBoxLayout(); - top_layout_->setSpacing(0); - top_layout_->setMargin(0); - - send_file_button_ = new FlatButton(this); + topLayout_ = new QHBoxLayout(); + topLayout_->setSpacing(2); + topLayout_->setMargin(4); QIcon send_file_icon; send_file_icon.addFile(":/icons/icons/clip-dark.png", QSize(), QIcon::Normal, QIcon::Off); - send_file_button_->setForegroundColor(QColor("#acc7dc")); - send_file_button_->setIcon(send_file_icon); - send_file_button_->setIconSize(QSize(24, 24)); + + sendFileBtn_ = new FlatButton(this); + sendFileBtn_->setForegroundColor(QColor("#acc7dc")); + sendFileBtn_->setIcon(send_file_icon); + sendFileBtn_->setIconSize(QSize(24, 24)); + + spinner_ = new LoadingIndicator(this); + spinner_->setColor("#acc7dc"); + spinner_->setFixedHeight(40); + spinner_->setFixedWidth(40); + spinner_->hide(); QFont font; font.setPixelSize(conf::fontSize); @@ -68,33 +76,34 @@ TextInputWidget::TextInputWidget(QWidget *parent) input_->setPlaceholderText(tr("Write a message...")); input_->setStyleSheet("color: #333333; border-radius: 0; padding-top: 10px;"); - send_message_button_ = new FlatButton(this); - send_message_button_->setForegroundColor(QColor("#acc7dc")); + sendMessageBtn_ = new FlatButton(this); + sendMessageBtn_->setForegroundColor(QColor("#acc7dc")); QIcon send_message_icon; send_message_icon.addFile( ":/icons/icons/share-dark.png", QSize(), QIcon::Normal, QIcon::Off); - send_message_button_->setIcon(send_message_icon); - send_message_button_->setIconSize(QSize(24, 24)); + sendMessageBtn_->setIcon(send_message_icon); + sendMessageBtn_->setIconSize(QSize(24, 24)); - emoji_button_ = new EmojiPickButton(this); - emoji_button_->setForegroundColor(QColor("#acc7dc")); + emojiBtn_ = new EmojiPickButton(this); + emojiBtn_->setForegroundColor(QColor("#acc7dc")); QIcon emoji_icon; emoji_icon.addFile(":/icons/icons/smile.png", QSize(), QIcon::Normal, QIcon::Off); - emoji_button_->setIcon(emoji_icon); - emoji_button_->setIconSize(QSize(24, 24)); + emojiBtn_->setIcon(emoji_icon); + emojiBtn_->setIconSize(QSize(24, 24)); - top_layout_->addWidget(send_file_button_); - top_layout_->addWidget(input_); - top_layout_->addWidget(emoji_button_); - top_layout_->addWidget(send_message_button_); + topLayout_->addWidget(sendFileBtn_); + topLayout_->addWidget(input_); + topLayout_->addWidget(emojiBtn_); + topLayout_->addWidget(sendMessageBtn_); - setLayout(top_layout_); + setLayout(topLayout_); - connect(send_message_button_, SIGNAL(clicked()), this, SLOT(onSendButtonClicked())); - connect(input_, SIGNAL(enterPressed()), send_message_button_, SIGNAL(clicked())); - connect(emoji_button_, + connect(sendMessageBtn_, SIGNAL(clicked()), this, SLOT(onSendButtonClicked())); + connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection())); + connect(input_, SIGNAL(enterPressed()), sendMessageBtn_, SIGNAL(clicked())); + connect(emojiBtn_, SIGNAL(emojiSelected(const QString &)), this, SLOT(addSelectedEmoji(const QString &))); @@ -155,6 +164,55 @@ TextInputWidget::parseEmoteCommand(const QString &cmd) return QString(""); } +void +TextInputWidget::openFileSelection() +{ + QStringList supportedFiles; + supportedFiles << "jpeg" + << "gif" + << "png" + << "bmp" + << "tiff" + << "webp"; + + auto fileName = QFileDialog::getOpenFileName( + this, + tr("Select an image"), + "", + tr("Image Files (*.bmp *.gif *.jpg *.jpeg *.png *.tiff *.webp)")); + + if (fileName.isEmpty()) + return; + + auto imageFormat = QString(QImageReader::imageFormat(fileName)); + if (!supportedFiles.contains(imageFormat)) { + qDebug() << "Unsupported image format for" << fileName; + return; + } + + emit uploadImage(fileName); + showUploadSpinner(); +} + +void +TextInputWidget::showUploadSpinner() +{ + topLayout_->removeWidget(sendFileBtn_); + sendFileBtn_->hide(); + + topLayout_->insertWidget(0, spinner_); + spinner_->start(); +} + +void +TextInputWidget::hideUploadSpinner() +{ + topLayout_->removeWidget(spinner_); + topLayout_->insertWidget(0, sendFileBtn_); + sendFileBtn_->show(); + spinner_->stop(); +} + TextInputWidget::~TextInputWidget() { } diff --git a/src/TimelineItem.cc b/src/TimelineItem.cc index 9d24a96c..92351d63 100644 --- a/src/TimelineItem.cc +++ b/src/TimelineItem.cc @@ -107,6 +107,39 @@ TimelineItem::TimelineItem(events::MessageEventType ty, mainLayout_->addWidget(body_); } +TimelineItem::TimelineItem(ImageItem *image, + const QString &userid, + bool withSender, + QWidget *parent) + : QWidget{ parent } +{ + init(); + + auto displayName = TimelineViewManager::displayName(userid); + auto timestamp = QDateTime::currentDateTime(); + + descriptionMsg_ = { "You", userid, " sent an image", descriptiveTime(timestamp) }; + + generateTimestamp(timestamp); + + auto imageLayout = new QHBoxLayout(); + imageLayout->setMargin(0); + imageLayout->addWidget(image); + imageLayout->addStretch(1); + + if (withSender) { + generateBody(displayName, ""); + setupAvatarLayout(displayName); + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(userid, this); + } else { + setupSimpleLayout(); + } + + mainLayout_->addLayout(imageLayout); +} + /* * Used to display images. The avatar and the username are displayed. */ diff --git a/src/TimelineView.cc b/src/TimelineView.cc index 518676ac..600ccb94 100644 --- a/src/TimelineView.cc +++ b/src/TimelineView.cc @@ -223,8 +223,9 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire eventIds_[text.eventId()] = true; - if (isPendingMessage(text, local_user_)) { - removePendingMessage(text); + if (isPendingMessage( + text.eventId(), text.content().body(), text.sender(), local_user_)) { + removePendingMessage(text.eventId(), text.content().body()); return nullptr; } @@ -245,7 +246,6 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire if (isDuplicate(notice.eventId())) return nullptr; - ; eventIds_[notice.eventId()] = true; @@ -269,6 +269,12 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire eventIds_[img.eventId()] = true; + if (isPendingMessage( + img.eventId(), img.msgContent().url(), img.sender(), local_user_)) { + removePendingMessage(img.eventId(), img.msgContent().url()); + return nullptr; + } + auto with_sender = isSenderRendered(img.sender(), direction); updateLastSender(img.sender(), direction); @@ -289,8 +295,11 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire eventIds_[emote.eventId()] = true; - if (isPendingMessage(emote, local_user_)) { - removePendingMessage(emote); + if (isPendingMessage(emote.eventId(), + emote.content().body(), + emote.sender(), + local_user_)) { + removePendingMessage(emote.eventId(), emote.content().body()); return nullptr; } @@ -471,6 +480,24 @@ TimelineView::addUserMessage(matrix::events::MessageEventType ty, const QString pending_msgs_.push_back(message); } +void +TimelineView::addUserMessage(const QString &url, const QString &filename, int txn_id) +{ + QSettings settings; + auto user_id = settings.value("auth/user_id").toString(); + auto with_sender = lastSender_ != user_id; + + auto image = new ImageItem(client_, url, filename, this); + + TimelineItem *view_item = new TimelineItem(image, user_id, with_sender, scroll_widget_); + scroll_layout_->addWidget(view_item); + + lastSender_ = user_id; + + PendingMessage message(txn_id, url, "", view_item); + pending_msgs_.push_back(message); +} + void TimelineView::notifyForLastEvent() { @@ -482,3 +509,33 @@ TimelineView::notifyForLastEvent() else qWarning() << "Cast to TimelineView failed" << room_id_; } + +bool +TimelineView::isPendingMessage(const QString &eventid, + const QString &body, + const QString &sender, + const QString &local_userid) +{ + if (sender != local_userid) + return false; + + for (const auto &msg : pending_msgs_) { + if (msg.event_id == eventid || msg.body == body) + return true; + } + + return false; +} + +void +TimelineView::removePendingMessage(const QString &eventid, const QString &body) +{ + for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); it++) { + int index = std::distance(pending_msgs_.begin(), it); + + if (it->event_id == eventid || it->body == body) { + pending_msgs_.removeAt(index); + break; + } + } +} diff --git a/src/TimelineViewManager.cc b/src/TimelineViewManager.cc index 0bb56bf9..2adbba34 100644 --- a/src/TimelineViewManager.cc +++ b/src/TimelineViewManager.cc @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -72,6 +73,23 @@ TimelineViewManager::sendEmoteMessage(const QString &msg) client_->sendRoomMessage(matrix::events::MessageEventType::Emote, room_id, msg); } +void +TimelineViewManager::sendImageMessage(const QString &roomid, + const QString &filename, + const QString &url) +{ + if (!views_.contains(roomid)) { + qDebug() << "Cannot send m.image message to a non-managed view"; + return; + } + + auto view = views_[roomid]; + + view->addUserMessage(url, filename, client_->transactionId()); + client_->sendRoomMessage( + matrix::events::MessageEventType::Image, roomid, QFileInfo(filename).fileName(), url); +} + void TimelineViewManager::clearAll() { diff --git a/src/ui/CircularProgress.cc b/src/ui/CircularProgress.cc deleted file mode 100644 index ca0b99a8..00000000 --- a/src/ui/CircularProgress.cc +++ /dev/null @@ -1,201 +0,0 @@ -#include -#include -#include -#include - -#include "CircularProgress.h" -#include "Theme.h" - -CircularProgress::CircularProgress(QWidget *parent) - : QProgressBar{ parent } - , progress_type_{ ui::ProgressType::IndeterminateProgress } - , width_{ 6.25 } - , size_{ 64 } - , duration_{ 3050 } -{ - delegate_ = new CircularProgressDelegate(this); - - setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::MinimumExpanding); - - auto group = new QParallelAnimationGroup(this); - group->setLoopCount(-1); - - auto length_animation = new QPropertyAnimation(this); - length_animation->setPropertyName("dashLength"); - length_animation->setTargetObject(delegate_); - length_animation->setEasingCurve(QEasingCurve::InOutQuad); - length_animation->setStartValue(0.1); - length_animation->setKeyValueAt(0.15, 3); - length_animation->setKeyValueAt(0.6, 20); - length_animation->setKeyValueAt(0.7, 20); - length_animation->setEndValue(20); - length_animation->setDuration(duration_); - - auto offset_animation = new QPropertyAnimation(this); - offset_animation->setPropertyName("dashOffset"); - offset_animation->setTargetObject(delegate_); - offset_animation->setEasingCurve(QEasingCurve::InOutSine); - offset_animation->setStartValue(0); - offset_animation->setKeyValueAt(0.15, 0); - offset_animation->setKeyValueAt(0.6, -7); - offset_animation->setKeyValueAt(0.7, -7); - offset_animation->setEndValue(-25); - offset_animation->setDuration(duration_); - - auto angle_animation = new QPropertyAnimation(this); - angle_animation->setPropertyName("angle"); - angle_animation->setTargetObject(delegate_); - angle_animation->setStartValue(0); - angle_animation->setEndValue(360); - angle_animation->setDuration(duration_); - - group->addAnimation(length_animation); - group->addAnimation(offset_animation); - group->addAnimation(angle_animation); - - group->start(); -} - -void -CircularProgress::setProgressType(ui::ProgressType type) -{ - progress_type_ = type; - update(); -} - -void -CircularProgress::setLineWidth(qreal width) -{ - width_ = width; - update(); - updateGeometry(); -} - -void -CircularProgress::setSize(int size) -{ - size_ = size; - update(); - updateGeometry(); -} - -ui::ProgressType -CircularProgress::progressType() const -{ - return progress_type_; -} - -qreal -CircularProgress::lineWidth() const -{ - return width_; -} - -int -CircularProgress::size() const -{ - return size_; -} - -void -CircularProgress::setColor(const QColor &color) -{ - color_ = color; -} - -QColor -CircularProgress::color() const -{ - if (!color_.isValid()) { - return QColor("red"); - } - - return color_; -} - -QSize -CircularProgress::sizeHint() const -{ - const qreal s = size_ + width_ + 8; - return QSize(s, s); -} - -void -CircularProgress::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - /* - * If the progress bar is disabled draw an X instead - */ - if (!isEnabled()) { - QPen pen; - pen.setCapStyle(Qt::RoundCap); - pen.setWidthF(lineWidth()); - pen.setColor("gray"); - - auto center = rect().center(); - - painter.setPen(pen); - painter.drawLine(center - QPointF(20, 20), center + QPointF(20, 20)); - painter.drawLine(center + QPointF(20, -20), center - QPointF(20, -20)); - - return; - } - - if (progress_type_ == ui::ProgressType::IndeterminateProgress) { - painter.translate(width() / 2, height() / 2); - painter.rotate(delegate_->angle()); - } - - QPen pen; - pen.setCapStyle(Qt::RoundCap); - pen.setWidthF(width_); - pen.setColor(color()); - - if (ui::ProgressType::IndeterminateProgress == progress_type_) { - QVector pattern; - pattern << delegate_->dashLength() * size_ / 50 << 30 * size_ / 50; - - pen.setDashOffset(delegate_->dashOffset() * size_ / 50); - pen.setDashPattern(pattern); - - painter.setPen(pen); - - painter.drawEllipse(QPoint(0, 0), size_ / 2, size_ / 2); - } else { - painter.setPen(pen); - - const qreal x = (width() - size_) / 2; - const qreal y = (height() - size_) / 2; - - const qreal a = 360 * (value() - minimum()) / (maximum() - minimum()); - - QPainterPath path; - path.arcMoveTo(x, y, size_, size_, 0); - path.arcTo(x, y, size_, size_, 0, a); - - painter.drawPath(path); - } -} - -CircularProgress::~CircularProgress() -{ -} - -CircularProgressDelegate::CircularProgressDelegate(CircularProgress *parent) - : QObject(parent) - , progress_(parent) - , dash_offset_(0) - , dash_length_(89) - , angle_(0) -{ - Q_ASSERT(parent); -} - -CircularProgressDelegate::~CircularProgressDelegate() -{ -} diff --git a/src/ui/LoadingIndicator.cc b/src/ui/LoadingIndicator.cc new file mode 100644 index 00000000..0fafaf23 --- /dev/null +++ b/src/ui/LoadingIndicator.cc @@ -0,0 +1,86 @@ +#include "LoadingIndicator.h" + +#include +#include +#include + +LoadingIndicator::LoadingIndicator(QWidget *parent) + : QWidget(parent) + , interval_(70) + , angle_(0) + , color_(Qt::black) +{ + setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + setFocusPolicy(Qt::NoFocus); + + timer_ = new QTimer(); + connect(timer_, SIGNAL(timeout()), this, SLOT(onTimeout())); +} + +LoadingIndicator::~LoadingIndicator() +{ + stop(); + + delete timer_; +} + +void +LoadingIndicator::paintEvent(QPaintEvent *e) +{ + Q_UNUSED(e) + + if (!timer_->isActive()) + return; + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + int width = qMin(this->width(), this->height()); + + int outerRadius = (width - 4) * 0.5f; + int innerRadius = outerRadius * 0.78f; + + int capsuleRadius = (outerRadius - innerRadius) / 2; + + for (int i = 0; i < 8; i++) { + QColor color = color_; + + color.setAlphaF(1.0f - (i / 8.0f)); + + painter.setPen(Qt::NoPen); + painter.setBrush(color); + + qreal radius = capsuleRadius * (1.0f - (i / 16.0f)); + + painter.save(); + + painter.translate(rect().center()); + painter.rotate(angle_ - i * 45.0f); + + QPointF center = QPointF(-capsuleRadius, -innerRadius); + painter.drawEllipse(center, radius * 2, radius * 2); + + painter.restore(); + } +} + +void +LoadingIndicator::start() +{ + timer_->start(interval_); + show(); +} + +void +LoadingIndicator::stop() +{ + timer_->stop(); + hide(); +} + +void +LoadingIndicator::onTimeout() +{ + angle_ = (angle_ + 45) % 360; + update(); +}