Merge branch 'images'

This commit is contained in:
Konstantinos Sideris 2017-09-10 13:13:02 +03:00
commit 29e21e2970
22 changed files with 521 additions and 427 deletions

View file

@ -141,7 +141,7 @@ set(SRC_FILES
src/ui/Avatar.cc src/ui/Avatar.cc
src/ui/Badge.cc src/ui/Badge.cc
src/ui/CircularProgress.cc src/ui/LoadingIndicator.cc
src/ui/FlatButton.cc src/ui/FlatButton.cc
src/ui/OverlayModal.cc src/ui/OverlayModal.cc
src/ui/ScrollBar.cc src/ui/ScrollBar.cc
@ -217,7 +217,7 @@ qt5_wrap_cpp(MOC_HEADERS
include/ui/Avatar.h include/ui/Avatar.h
include/ui/Badge.h include/ui/Badge.h
include/ui/CircularProgress.h include/ui/LoadingIndicator.h
include/ui/FlatButton.h include/ui/FlatButton.h
include/ui/OverlayWidget.h include/ui/OverlayWidget.h
include/ui/ScrollBar.h include/ui/ScrollBar.h

View file

@ -36,6 +36,11 @@ public:
const events::MessageEvent<msgs::Image> &event, const events::MessageEvent<msgs::Image> &event,
QWidget *parent = nullptr); QWidget *parent = nullptr);
ImageItem(QSharedPointer<MatrixClient> client,
const QString &url,
const QString &filename,
QWidget *parent = nullptr);
void setImage(const QPixmap &image); void setImage(const QPixmap &image);
QSize sizeHint() const override; QSize sizeHint() const override;

View file

@ -23,8 +23,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include "CircularProgress.h"
#include "FlatButton.h" #include "FlatButton.h"
#include "LoadingIndicator.h"
#include "MatrixClient.h" #include "MatrixClient.h"
#include "OverlayModal.h" #include "OverlayModal.h"
#include "RaisedButton.h" #include "RaisedButton.h"
@ -79,7 +79,7 @@ private:
QHBoxLayout *serverLayout_; QHBoxLayout *serverLayout_;
QHBoxLayout *matrixidLayout_; QHBoxLayout *matrixidLayout_;
CircularProgress *spinner_; LoadingIndicator *spinner_;
QLabel *errorIcon_; QLabel *errorIcon_;
QString inferredServerAddress_; QString inferredServerAddress_;

View file

@ -21,7 +21,7 @@
#include <QSharedPointer> #include <QSharedPointer>
#include "ChatPage.h" #include "ChatPage.h"
#include "CircularProgress.h" #include "LoadingIndicator.h"
#include "LoginPage.h" #include "LoginPage.h"
#include "MatrixClient.h" #include "MatrixClient.h"
#include "OverlayModal.h" #include "OverlayModal.h"
@ -85,7 +85,7 @@ private:
// Used to hide undefined states between page transitions. // Used to hide undefined states between page transitions.
OverlayModal *progress_modal_; OverlayModal *progress_modal_;
CircularProgress *spinner_; LoadingIndicator *spinner_;
// Matrix Client API provider. // Matrix Client API provider.
QSharedPointer<MatrixClient> client_; QSharedPointer<MatrixClient> client_;

View file

@ -39,7 +39,8 @@ public:
void sync() noexcept; void sync() noexcept;
void sendRoomMessage(matrix::events::MessageEventType ty, void sendRoomMessage(matrix::events::MessageEventType ty,
const QString &roomid, const QString &roomid,
const QString &msg) noexcept; const QString &msg,
const QString &url = "") noexcept;
void login(const QString &username, const QString &password) noexcept; void login(const QString &username, const QString &password) noexcept;
void registerUser(const QString &username, void registerUser(const QString &username,
const QString &password, const QString &password,
@ -50,6 +51,7 @@ public:
void fetchOwnAvatar(const QUrl &avatar_url); void fetchOwnAvatar(const QUrl &avatar_url);
void downloadImage(const QString &event_id, const QUrl &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 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 QUrl getHomeServer();
inline int transactionId(); inline int transactionId();
@ -77,6 +79,7 @@ signals:
const QString &homeserver, const QString &homeserver,
const QString &token); const QString &token);
void versionSuccess(); void versionSuccess();
void imageUploaded(const QString &roomid, const QString &filename, const QString &url);
void roomAvatarRetrieved(const QString &roomid, const QPixmap &img); void roomAvatarRetrieved(const QString &roomid, const QPixmap &img);
void userAvatarRetrieved(const QString &userId, const QImage &img); void userAvatarRetrieved(const QString &userId, const QImage &img);
@ -102,6 +105,7 @@ private:
GetProfile, GetProfile,
Image, Image,
InitialSync, InitialSync,
ImageUpload,
Login, Login,
Logout, Logout,
Messages, Messages,
@ -118,6 +122,7 @@ private:
void onGetOwnProfileResponse(QNetworkReply *reply); void onGetOwnProfileResponse(QNetworkReply *reply);
void onImageResponse(QNetworkReply *reply); void onImageResponse(QNetworkReply *reply);
void onInitialSyncResponse(QNetworkReply *reply); void onInitialSyncResponse(QNetworkReply *reply);
void onImageUploadResponse(QNetworkReply *reply);
void onLoginResponse(QNetworkReply *reply); void onLoginResponse(QNetworkReply *reply);
void onLogoutResponse(QNetworkReply *reply); void onLogoutResponse(QNetworkReply *reply);
void onMessagesResponse(QNetworkReply *reply); void onMessagesResponse(QNetworkReply *reply);
@ -129,7 +134,10 @@ private:
void onVersionsResponse(QNetworkReply *reply); void onVersionsResponse(QNetworkReply *reply);
// Client API prefix. // Client API prefix.
QString api_url_; QString clientApiUrl_;
// Media API prefix.
QString mediaApiUrl_;
// The Matrix server used for communication. // The Matrix server used for communication.
QUrl server_; QUrl server_;

View file

@ -24,6 +24,10 @@
#include "EmojiPickButton.h" #include "EmojiPickButton.h"
#include "FlatButton.h" #include "FlatButton.h"
#include "Image.h"
#include "LoadingIndicator.h"
namespace msgs = matrix::events::messages;
static const QString EMOTE_COMMAND("/me "); static const QString EMOTE_COMMAND("/me ");
@ -48,6 +52,8 @@ public:
public slots: public slots:
void onSendButtonClicked(); void onSendButtonClicked();
void openFileSelection();
void hideUploadSpinner();
inline void focusLineEdit(); inline void focusLineEdit();
private slots: private slots:
@ -56,16 +62,20 @@ private slots:
signals: signals:
void sendTextMessage(QString msg); void sendTextMessage(QString msg);
void sendEmoteMessage(QString msg); void sendEmoteMessage(QString msg);
void uploadImage(QString filename);
private: private:
void showUploadSpinner();
QString parseEmoteCommand(const QString &cmd); QString parseEmoteCommand(const QString &cmd);
QHBoxLayout *top_layout_; QHBoxLayout *topLayout_;
FilteredTextEdit *input_; FilteredTextEdit *input_;
FlatButton *send_file_button_; LoadingIndicator *spinner_;
FlatButton *send_message_button_;
EmojiPickButton *emoji_button_; FlatButton *sendFileBtn_;
FlatButton *sendMessageBtn_;
EmojiPickButton *emojiBtn_;
}; };
inline void inline void

View file

@ -50,11 +50,14 @@ public:
QWidget *parent = 0); QWidget *parent = 0);
// For local messages. // For local messages.
// m.text & m.emote
TimelineItem(events::MessageEventType ty, TimelineItem(events::MessageEventType ty,
const QString &userid, const QString &userid,
QString body, QString body,
bool withSender, bool withSender,
QWidget *parent = 0); QWidget *parent = 0);
// m.image
TimelineItem(ImageItem *item, const QString &userid, bool withSender, QWidget *parent = 0);
TimelineItem(ImageItem *img, TimelineItem(ImageItem *img,
const events::MessageEvent<msgs::Image> &e, const events::MessageEvent<msgs::Image> &e,

View file

@ -85,6 +85,7 @@ public:
// Add new events at the end of the timeline. // Add new events at the end of the timeline.
int addEvents(const Timeline &timeline); int addEvents(const Timeline &timeline);
void addUserMessage(matrix::events::MessageEventType ty, const QString &msg, int txn_id); 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 updatePendingMessage(int txn_id, QString event_id);
void scrollDown(); void scrollDown();
@ -108,11 +109,11 @@ private:
// Used to determine whether or not we should prefix a message with the sender's name. // 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 isSenderRendered(const QString &user_id, TimelineDirection direction);
template<class T> bool isPendingMessage(const QString &eventid,
bool isPendingMessage(const events::MessageEvent<T> &e, const QString &userid); const QString &body,
const QString &sender,
template<class T> const QString &userid);
void removePendingMessage(const events::MessageEvent<T> &e); void removePendingMessage(const QString &eventid, const QString &body);
inline bool isDuplicate(const QString &event_id); inline bool isDuplicate(const QString &event_id);
@ -159,32 +160,3 @@ TimelineView::isDuplicate(const QString &event_id)
{ {
return eventIds_.contains(event_id); return eventIds_.contains(event_id);
} }
template<class T>
bool
TimelineView::isPendingMessage(const events::MessageEvent<T> &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<class T>
void
TimelineView::removePendingMessage(const events::MessageEvent<T> &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;
}
}
}

View file

@ -56,6 +56,7 @@ public slots:
void setHistoryView(const QString &room_id); void setHistoryView(const QString &room_id);
void sendTextMessage(const QString &msg); void sendTextMessage(const QString &msg);
void sendEmoteMessage(const QString &msg); void sendEmoteMessage(const QString &msg);
void sendImageMessage(const QString &roomid, const QString &filename, const QString &url);
private slots: private slots:
void messageSent(const QString &eventid, const QString &roomid, int txnid); void messageSent(const QString &eventid, const QString &roomid, int txnid);

View file

@ -1,120 +0,0 @@
#pragma once
#include <QObject>
#include <QProgressBar>
#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_;
}

View file

@ -0,0 +1,49 @@
#pragma once
#include <QColor>
#include <QPaintEvent>
#include <QPainter>
#include <QTimer>
#include <QWidget>
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_;
};

View file

@ -153,6 +153,18 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client, QWidget *parent)
view_manager_, view_manager_,
SLOT(sendEmoteMessage(const QString &))); 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(), connect(client_.data(),
SIGNAL(roomAvatarRetrieved(const QString &, const QPixmap &)), SIGNAL(roomAvatarRetrieved(const QString &, const QPixmap &)),
this, this,

View file

@ -18,6 +18,7 @@
#include <QBrush> #include <QBrush>
#include <QDebug> #include <QDebug>
#include <QDesktopServices> #include <QDesktopServices>
#include <QFileInfo>
#include <QImage> #include <QImage>
#include <QPainter> #include <QPainter>
#include <QPixmap> #include <QPixmap>
@ -61,6 +62,33 @@ ImageItem::ImageItem(QSharedPointer<MatrixClient> client,
SLOT(imageDownloaded(const QString &, const QPixmap &))); SLOT(imageDownloaded(const QString &, const QPixmap &)));
} }
ImageItem::ImageItem(QSharedPointer<MatrixClient> 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<QString> 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 void
ImageItem::imageDownloaded(const QString &event_id, const QPixmap &img) ImageItem::imageDownloaded(const QString &event_id, const QPixmap &img)
{ {

View file

@ -77,10 +77,10 @@ LoginPage::LoginPage(QSharedPointer<MatrixClient> client, QWidget *parent)
matrixid_input_->setBackgroundColor("#f9f9f9"); matrixid_input_->setBackgroundColor("#f9f9f9");
matrixid_input_->setPlaceholderText(tr("e.g @joe:matrix.org")); matrixid_input_->setPlaceholderText(tr("e.g @joe:matrix.org"));
spinner_ = new CircularProgress(this); spinner_ = new LoadingIndicator(this);
spinner_->setColor("#acc7dc"); spinner_->setColor("#acc7dc");
spinner_->setSize(32); spinner_->setFixedHeight(40);
spinner_->setMaximumWidth(spinner_->width()); spinner_->setFixedWidth(40);
spinner_->hide(); spinner_->hide();
errorIcon_ = new QLabel(this); errorIcon_ = new QLabel(this);
@ -192,11 +192,11 @@ LoginPage::onMatrixIdEntered()
if (serverInput_->isVisible()) { if (serverInput_->isVisible()) {
matrixidLayout_->removeWidget(spinner_); matrixidLayout_->removeWidget(spinner_);
serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight);
spinner_->show(); spinner_->start();
} else { } else {
serverLayout_->removeWidget(spinner_); serverLayout_->removeWidget(spinner_);
matrixidLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); matrixidLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight);
spinner_->show(); spinner_->start();
} }
inferredServerAddress_ = homeServer; inferredServerAddress_ = homeServer;
@ -216,7 +216,7 @@ LoginPage::onServerAddressEntered()
serverLayout_->removeWidget(errorIcon_); serverLayout_->removeWidget(errorIcon_);
errorIcon_->hide(); errorIcon_->hide();
serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight); serverLayout_->addWidget(spinner_, 0, Qt::AlignVCenter | Qt::AlignRight);
spinner_->show(); spinner_->start();
} }
void void
@ -240,7 +240,7 @@ LoginPage::versionError(QString error)
error_label_->setText(error); error_label_->setText(error);
serverInput_->show(); serverInput_->show();
spinner_->hide(); spinner_->stop();
serverLayout_->removeWidget(spinner_); serverLayout_->removeWidget(spinner_);
serverLayout_->addWidget(errorIcon_, 0, Qt::AlignVCenter | Qt::AlignRight); serverLayout_->addWidget(errorIcon_, 0, Qt::AlignVCenter | Qt::AlignRight);
errorIcon_->show(); errorIcon_->show();
@ -252,7 +252,7 @@ LoginPage::versionSuccess()
{ {
serverLayout_->removeWidget(spinner_); serverLayout_->removeWidget(spinner_);
matrixidLayout_->removeWidget(spinner_); matrixidLayout_->removeWidget(spinner_);
spinner_->hide(); spinner_->stop();
if (serverInput_->isVisible()) if (serverInput_->isVisible())
serverInput_->hide(); serverInput_->hide();
@ -282,7 +282,7 @@ LoginPage::reset()
password_input_->clear(); password_input_->clear();
serverInput_->clear(); serverInput_->clear();
spinner_->hide(); spinner_->stop();
errorIcon_->hide(); errorIcon_->hide();
serverLayout_->removeWidget(spinner_); serverLayout_->removeWidget(spinner_);
serverLayout_->removeWidget(errorIcon_); serverLayout_->removeWidget(errorIcon_);

View file

@ -15,8 +15,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
#include "MainWindow.h"
#include "Config.h" #include "Config.h"
#include "MainWindow.h"
#include <QLayout> #include <QLayout>
#include <QNetworkReply> #include <QNetworkReply>
@ -135,6 +135,8 @@ MainWindow::removeOverlayProgressBar()
if (spinner_ != nullptr) if (spinner_ != nullptr)
spinner_->deleteLater(); spinner_->deleteLater();
spinner_->stop();
progress_modal_ = nullptr; progress_modal_ = nullptr;
spinner_ = nullptr; spinner_ = nullptr;
}); });
@ -163,9 +165,11 @@ MainWindow::showChatPage(QString userid, QString homeserver, QString token)
} }
if (spinner_ == nullptr) { if (spinner_ == nullptr) {
spinner_ = new CircularProgress(this); spinner_ = new LoadingIndicator(this);
spinner_->setColor("#acc7dc"); spinner_->setColor("#acc7dc");
spinner_->setSize(100); spinner_->setFixedHeight(120);
spinner_->setFixedWidth(120);
spinner_->start();
} }
if (progress_modal_ == nullptr) { if (progress_modal_ == nullptr) {

View file

@ -16,6 +16,8 @@
*/ */
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QImageReader>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
@ -34,11 +36,10 @@
MatrixClient::MatrixClient(QString server, QObject *parent) MatrixClient::MatrixClient(QString server, QObject *parent)
: QNetworkAccessManager(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; QSettings settings;
txn_id_ = settings.value("client/transaction_id", 1).toInt(); txn_id_ = settings.value("client/transaction_id", 1).toInt();
@ -236,6 +237,42 @@ MatrixClient::onInitialSyncResponse(QNetworkReply *reply)
emit initialSyncCompleted(response); 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 void
MatrixClient::onSyncResponse(QNetworkReply *reply) MatrixClient::onSyncResponse(QNetworkReply *reply)
{ {
@ -450,6 +487,9 @@ MatrixClient::onResponse(QNetworkReply *reply)
case Endpoint::InitialSync: case Endpoint::InitialSync:
onInitialSyncResponse(reply); onInitialSyncResponse(reply);
break; break;
case Endpoint::ImageUpload:
onImageUploadResponse(reply);
break;
case Endpoint::Sync: case Endpoint::Sync:
onSyncResponse(reply); onSyncResponse(reply);
break; break;
@ -477,7 +517,7 @@ void
MatrixClient::login(const QString &username, const QString &password) noexcept MatrixClient::login(const QString &username, const QString &password) noexcept
{ {
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/login"); endpoint.setPath(clientApiUrl_ + "/login");
QNetworkRequest request(endpoint); QNetworkRequest request(endpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@ -495,7 +535,7 @@ MatrixClient::logout() noexcept
query.addQueryItem("access_token", token_); query.addQueryItem("access_token", token_);
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/logout"); endpoint.setPath(clientApiUrl_ + "/logout");
endpoint.setQuery(query); endpoint.setQuery(query);
QNetworkRequest request(endpoint); QNetworkRequest request(endpoint);
@ -515,7 +555,7 @@ MatrixClient::registerUser(const QString &user, const QString &pass, const QStri
query.addQueryItem("kind", "user"); query.addQueryItem("kind", "user");
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/register"); endpoint.setPath(clientApiUrl_ + "/register");
endpoint.setQuery(query); endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded())); QNetworkRequest request(QString(endpoint.toEncoded()));
@ -549,7 +589,7 @@ MatrixClient::sync() noexcept
query.addQueryItem("since", next_batch_); query.addQueryItem("since", next_batch_);
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/sync"); endpoint.setPath(clientApiUrl_ + "/sync");
endpoint.setQuery(query); endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded())); QNetworkRequest request(QString(endpoint.toEncoded()));
@ -561,32 +601,35 @@ MatrixClient::sync() noexcept
void void
MatrixClient::sendRoomMessage(matrix::events::MessageEventType ty, MatrixClient::sendRoomMessage(matrix::events::MessageEventType ty,
const QString &roomid, const QString &roomid,
const QString &msg) noexcept const QString &msg,
const QString &url) noexcept
{ {
QUrlQuery query; QUrlQuery query;
query.addQueryItem("access_token", token_); query.addQueryItem("access_token", token_);
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + endpoint.setPath(clientApiUrl_ +
QString("/rooms/%1/send/m.room.message/%2").arg(roomid).arg(txn_id_)); QString("/rooms/%1/send/m.room.message/%2").arg(roomid).arg(txn_id_));
endpoint.setQuery(query); endpoint.setQuery(query);
QString msgType(""); QString msgType("");
QJsonObject body;
switch (ty) { switch (ty) {
case matrix::events::MessageEventType::Text: case matrix::events::MessageEventType::Text:
msgType = "m.text"; body = { { "msgtype", "m.text" }, { "body", msg } };
break; break;
case matrix::events::MessageEventType::Emote: 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; break;
default: default:
msgType = "m.text"; qDebug() << "SendRoomMessage: Unknown message type for" << msg;
break; return;
} }
QJsonObject body{ { "msgtype", msgType }, { "body", msg } };
QNetworkRequest request(QString(endpoint.toEncoded())); QNetworkRequest request(QString(endpoint.toEncoded()));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@ -617,7 +660,7 @@ MatrixClient::initialSync() noexcept
query.addQueryItem("access_token", token_); query.addQueryItem("access_token", token_);
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/sync"); endpoint.setPath(clientApiUrl_ + "/sync");
endpoint.setQuery(query); endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded())); QNetworkRequest request(QString(endpoint.toEncoded()));
@ -650,7 +693,7 @@ MatrixClient::getOwnProfile() noexcept
query.addQueryItem("access_token", token_); query.addQueryItem("access_token", token_);
QUrl endpoint(server_); QUrl endpoint(server_);
endpoint.setPath(api_url_ + "/profile/" + userid); endpoint.setPath(clientApiUrl_ + "/profile/" + userid);
endpoint.setQuery(query); endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded())); 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)); query.addQueryItem("limit", QString::number(limit));
QUrl endpoint(server_); 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); endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded())); 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<int>(Endpoint::Messages)); reply->setProperty("endpoint", static_cast<int>(Endpoint::Messages));
reply->setProperty("room_id", room_id); 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<int>(Endpoint::ImageUpload));
reply->setProperty("room_id", roomid);
reply->setProperty("filename", filename);
}

View file

@ -17,6 +17,8 @@
#include <QDebug> #include <QDebug>
#include <QFile> #include <QFile>
#include <QFileDialog>
#include <QImageReader>
#include <QPainter> #include <QPainter>
#include <QStyleOption> #include <QStyleOption>
@ -47,17 +49,23 @@ TextInputWidget::TextInputWidget(QWidget *parent)
setCursor(Qt::ArrowCursor); setCursor(Qt::ArrowCursor);
setStyleSheet("background-color: #f8fbfe; height: 45px;"); setStyleSheet("background-color: #f8fbfe; height: 45px;");
top_layout_ = new QHBoxLayout(); topLayout_ = new QHBoxLayout();
top_layout_->setSpacing(0); topLayout_->setSpacing(2);
top_layout_->setMargin(0); topLayout_->setMargin(4);
send_file_button_ = new FlatButton(this);
QIcon send_file_icon; QIcon send_file_icon;
send_file_icon.addFile(":/icons/icons/clip-dark.png", QSize(), QIcon::Normal, QIcon::Off); 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); sendFileBtn_ = new FlatButton(this);
send_file_button_->setIconSize(QSize(24, 24)); 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; QFont font;
font.setPixelSize(conf::fontSize); font.setPixelSize(conf::fontSize);
@ -68,33 +76,34 @@ TextInputWidget::TextInputWidget(QWidget *parent)
input_->setPlaceholderText(tr("Write a message...")); input_->setPlaceholderText(tr("Write a message..."));
input_->setStyleSheet("color: #333333; border-radius: 0; padding-top: 10px;"); input_->setStyleSheet("color: #333333; border-radius: 0; padding-top: 10px;");
send_message_button_ = new FlatButton(this); sendMessageBtn_ = new FlatButton(this);
send_message_button_->setForegroundColor(QColor("#acc7dc")); sendMessageBtn_->setForegroundColor(QColor("#acc7dc"));
QIcon send_message_icon; QIcon send_message_icon;
send_message_icon.addFile( send_message_icon.addFile(
":/icons/icons/share-dark.png", QSize(), QIcon::Normal, QIcon::Off); ":/icons/icons/share-dark.png", QSize(), QIcon::Normal, QIcon::Off);
send_message_button_->setIcon(send_message_icon); sendMessageBtn_->setIcon(send_message_icon);
send_message_button_->setIconSize(QSize(24, 24)); sendMessageBtn_->setIconSize(QSize(24, 24));
emoji_button_ = new EmojiPickButton(this); emojiBtn_ = new EmojiPickButton(this);
emoji_button_->setForegroundColor(QColor("#acc7dc")); emojiBtn_->setForegroundColor(QColor("#acc7dc"));
QIcon emoji_icon; QIcon emoji_icon;
emoji_icon.addFile(":/icons/icons/smile.png", QSize(), QIcon::Normal, QIcon::Off); emoji_icon.addFile(":/icons/icons/smile.png", QSize(), QIcon::Normal, QIcon::Off);
emoji_button_->setIcon(emoji_icon); emojiBtn_->setIcon(emoji_icon);
emoji_button_->setIconSize(QSize(24, 24)); emojiBtn_->setIconSize(QSize(24, 24));
top_layout_->addWidget(send_file_button_); topLayout_->addWidget(sendFileBtn_);
top_layout_->addWidget(input_); topLayout_->addWidget(input_);
top_layout_->addWidget(emoji_button_); topLayout_->addWidget(emojiBtn_);
top_layout_->addWidget(send_message_button_); topLayout_->addWidget(sendMessageBtn_);
setLayout(top_layout_); setLayout(topLayout_);
connect(send_message_button_, SIGNAL(clicked()), this, SLOT(onSendButtonClicked())); connect(sendMessageBtn_, SIGNAL(clicked()), this, SLOT(onSendButtonClicked()));
connect(input_, SIGNAL(enterPressed()), send_message_button_, SIGNAL(clicked())); connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection()));
connect(emoji_button_, connect(input_, SIGNAL(enterPressed()), sendMessageBtn_, SIGNAL(clicked()));
connect(emojiBtn_,
SIGNAL(emojiSelected(const QString &)), SIGNAL(emojiSelected(const QString &)),
this, this,
SLOT(addSelectedEmoji(const QString &))); SLOT(addSelectedEmoji(const QString &)));
@ -155,6 +164,55 @@ TextInputWidget::parseEmoteCommand(const QString &cmd)
return QString(""); 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() TextInputWidget::~TextInputWidget()
{ {
} }

View file

@ -107,6 +107,39 @@ TimelineItem::TimelineItem(events::MessageEventType ty,
mainLayout_->addWidget(body_); 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. * Used to display images. The avatar and the username are displayed.
*/ */

View file

@ -223,8 +223,9 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire
eventIds_[text.eventId()] = true; eventIds_[text.eventId()] = true;
if (isPendingMessage(text, local_user_)) { if (isPendingMessage(
removePendingMessage(text); text.eventId(), text.content().body(), text.sender(), local_user_)) {
removePendingMessage(text.eventId(), text.content().body());
return nullptr; return nullptr;
} }
@ -245,7 +246,6 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire
if (isDuplicate(notice.eventId())) if (isDuplicate(notice.eventId()))
return nullptr; return nullptr;
;
eventIds_[notice.eventId()] = true; eventIds_[notice.eventId()] = true;
@ -269,6 +269,12 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire
eventIds_[img.eventId()] = true; 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); auto with_sender = isSenderRendered(img.sender(), direction);
updateLastSender(img.sender(), direction); updateLastSender(img.sender(), direction);
@ -289,8 +295,11 @@ TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection dire
eventIds_[emote.eventId()] = true; eventIds_[emote.eventId()] = true;
if (isPendingMessage(emote, local_user_)) { if (isPendingMessage(emote.eventId(),
removePendingMessage(emote); emote.content().body(),
emote.sender(),
local_user_)) {
removePendingMessage(emote.eventId(), emote.content().body());
return nullptr; return nullptr;
} }
@ -471,6 +480,24 @@ TimelineView::addUserMessage(matrix::events::MessageEventType ty, const QString
pending_msgs_.push_back(message); 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 void
TimelineView::notifyForLastEvent() TimelineView::notifyForLastEvent()
{ {
@ -482,3 +509,33 @@ TimelineView::notifyForLastEvent()
else else
qWarning() << "Cast to TimelineView failed" << room_id_; 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;
}
}
}

View file

@ -19,6 +19,7 @@
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QFileInfo>
#include <QSettings> #include <QSettings>
#include <QStackedWidget> #include <QStackedWidget>
#include <QWidget> #include <QWidget>
@ -72,6 +73,23 @@ TimelineViewManager::sendEmoteMessage(const QString &msg)
client_->sendRoomMessage(matrix::events::MessageEventType::Emote, room_id, 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 void
TimelineViewManager::clearAll() TimelineViewManager::clearAll()
{ {

View file

@ -1,201 +0,0 @@
#include <QPainter>
#include <QParallelAnimationGroup>
#include <QPen>
#include <QPropertyAnimation>
#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<qreal> 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()
{
}

View file

@ -0,0 +1,86 @@
#include "LoadingIndicator.h"
#include <QDebug>
#include <QPoint>
#include <QtGlobal>
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();
}