summary refs log tree commit diff
path: root/src/timeline
diff options
context:
space:
mode:
authorKonstantinos Sideris <sideris.konstantin@gmail.com>2017-11-30 13:53:28 +0200
committerKonstantinos Sideris <sideris.konstantin@gmail.com>2017-11-30 13:53:28 +0200
commit32c83405771b2f7a751783529d17e1b84dad4224 (patch)
tree70b51c8f019bfcaae99207fba8d0214e4b69e94d /src/timeline
parentUse templates for the TimelineItem generation (diff)
downloadnheko-32c83405771b2f7a751783529d17e1b84dad4224.tar.xz
Create directories for related files
Diffstat (limited to 'src/timeline')
-rw-r--r--src/timeline/TimelineItem.cc507
-rw-r--r--src/timeline/TimelineView.cc561
-rw-r--r--src/timeline/TimelineViewManager.cc301
-rw-r--r--src/timeline/widgets/FileItem.cc208
-rw-r--r--src/timeline/widgets/ImageItem.cc223
5 files changed, 1800 insertions, 0 deletions
diff --git a/src/timeline/TimelineItem.cc b/src/timeline/TimelineItem.cc
new file mode 100644

index 00000000..f7dd0f6e --- /dev/null +++ b/src/timeline/TimelineItem.cc
@@ -0,0 +1,507 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +#include <QFontDatabase> +#include <QRegExp> +#include <QSettings> +#include <QTextEdit> + +#include "Avatar.h" +#include "Config.h" +#include "Sync.h" + +#include "timeline/TimelineItem.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" + +static const QRegExp URL_REGEX("((?:https?|ftp)://\\S+)"); +static const QString URL_HTML = "<a href=\"\\1\">\\1</a>"; + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +void +TimelineItem::init() +{ + userAvatar_ = nullptr; + timestamp_ = nullptr; + userName_ = nullptr; + body_ = nullptr; + + font_.setPixelSize(conf::fontSize); + + QFontMetrics fm(font_); + + topLayout_ = new QHBoxLayout(this); + sideLayout_ = new QVBoxLayout(); + mainLayout_ = new QVBoxLayout(); + headerLayout_ = new QHBoxLayout(); + + topLayout_->setContentsMargins(conf::timeline::msgMargin, conf::timeline::msgMargin, 0, 0); + topLayout_->setSpacing(0); + + topLayout_->addLayout(sideLayout_); + topLayout_->addLayout(mainLayout_, 1); + + sideLayout_->setMargin(0); + sideLayout_->setSpacing(0); + + mainLayout_->setContentsMargins(conf::timeline::headerLeftMargin, 0, 0, 0); + mainLayout_->setSpacing(0); + + headerLayout_->setMargin(0); + headerLayout_->setSpacing(conf::timeline::headerSpacing); +} + +/* + * For messages created locally. + */ +TimelineItem::TimelineItem(events::MessageEventType ty, + const QString &userid, + QString body, + bool withSender, + QWidget *parent) + : QWidget(parent) +{ + init(); + + auto displayName = TimelineViewManager::displayName(userid); + auto timestamp = QDateTime::currentDateTime(); + + if (ty == events::MessageEventType::Emote) { + body = QString("* %1 %2").arg(displayName).arg(body); + descriptionMsg_ = {"", userid, body, descriptiveTime(timestamp)}; + } else { + descriptionMsg_ = { + "You: ", userid, body, descriptiveTime(QDateTime::currentDateTime())}; + } + + body = body.toHtmlEscaped(); + body.replace(URL_REGEX, URL_HTML); + body.replace("\n", "<br/>"); + generateTimestamp(timestamp); + + if (withSender) { + generateBody(displayName, body); + setupAvatarLayout(displayName); + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(userid, this); + } else { + generateBody(body); + setupSimpleLayout(); + } + + mainLayout_->addWidget(body_); +} + +TimelineItem::TimelineItem(ImageItem *image, + const QString &userid, + bool withSender, + QWidget *parent) + : QWidget{parent} +{ + init(); + + setupLocalWidgetLayout<ImageItem>(image, userid, "sent an image", withSender); +} + +TimelineItem::TimelineItem(FileItem *file, const QString &userid, bool withSender, QWidget *parent) + : QWidget{parent} +{ + init(); + + setupLocalWidgetLayout<FileItem>(file, userid, "sent a file", withSender); +} + +/* + * Used to display images. The avatar and the username are displayed. + */ +TimelineItem::TimelineItem(ImageItem *image, + const events::MessageEvent<msgs::Image> &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 an image", + descriptiveTime(QDateTime::fromMSecsSinceEpoch(event.timestamp()))}; + + generateTimestamp(timestamp); + + auto imageLayout = new QHBoxLayout(); + imageLayout->setContentsMargins(0, 5, 0, 0); + imageLayout->addWidget(image); + imageLayout->addStretch(1); + + if (with_sender) { + generateBody(displayName, ""); + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(event.sender(), this); + } else { + setupSimpleLayout(); + } + + mainLayout_->addLayout(imageLayout); +} + +TimelineItem::TimelineItem(FileItem *file, + const events::MessageEvent<msgs::File> &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. + */ +TimelineItem::TimelineItem(const events::MessageEvent<msgs::Notice> &event, + bool with_sender, + QWidget *parent) + : QWidget(parent) +{ + init(); + + event_id_ = event.eventId(); + + descriptionMsg_ = {TimelineViewManager::displayName(event.sender()), + event.sender(), + " sent a notification", + descriptiveTime(QDateTime::fromMSecsSinceEpoch(event.timestamp()))}; + + auto body = event.content().body().trimmed().toHtmlEscaped(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); + + generateTimestamp(timestamp); + + body.replace(URL_REGEX, URL_HTML); + body.replace("\n", "<br/>"); + body = "<i>" + body + "</i>"; + + if (with_sender) { + auto displayName = TimelineViewManager::displayName(event.sender()); + + generateBody(displayName, body); + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(event.sender(), this); + } else { + generateBody(body); + setupSimpleLayout(); + } + + mainLayout_->addWidget(body_); +} + +/* + * Used to display remote emote messages. + */ +TimelineItem::TimelineItem(const events::MessageEvent<msgs::Emote> &event, + bool with_sender, + QWidget *parent) + : QWidget(parent) +{ + init(); + + event_id_ = event.eventId(); + + auto body = event.content().body().trimmed(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp()); + auto displayName = TimelineViewManager::displayName(event.sender()); + auto emoteMsg = QString("* %1 %2").arg(displayName).arg(body); + + descriptionMsg_ = {"", + event.sender(), + emoteMsg, + descriptiveTime(QDateTime::fromMSecsSinceEpoch(event.timestamp()))}; + + generateTimestamp(timestamp); + emoteMsg = emoteMsg.toHtmlEscaped(); + emoteMsg.replace(URL_REGEX, URL_HTML); + emoteMsg.replace("\n", "<br/>"); + + if (with_sender) { + generateBody(displayName, emoteMsg); + setupAvatarLayout(displayName); + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(event.sender(), this); + } else { + generateBody(emoteMsg); + setupSimpleLayout(); + } + + mainLayout_->addWidget(body_); +} + +/* + * Used to display remote text messages. + */ +TimelineItem::TimelineItem(const events::MessageEvent<msgs::Text> &event, + bool with_sender, + QWidget *parent) + : QWidget(parent) +{ + init(); + + event_id_ = event.eventId(); + + auto body = event.content().body().trimmed(); + 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(), + QString(": %1").arg(body), + descriptiveTime(QDateTime::fromMSecsSinceEpoch(event.timestamp()))}; + + generateTimestamp(timestamp); + + body = body.toHtmlEscaped(); + body.replace(URL_REGEX, URL_HTML); + body.replace("\n", "<br/>"); + + if (with_sender) { + generateBody(displayName, body); + setupAvatarLayout(displayName); + + mainLayout_->addLayout(headerLayout_); + + AvatarProvider::resolve(event.sender(), this); + } else { + generateBody(body); + setupSimpleLayout(); + } + + mainLayout_->addWidget(body_); +} + +// Only the body is displayed. +void +TimelineItem::generateBody(const QString &body) +{ + QString content("<span> %1 </span>"); + + body_ = new QLabel(this); + body_->setFont(font_); + body_->setWordWrap(true); + body_->setText(content.arg(replaceEmoji(body))); + body_->setMargin(0); + + body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + body_->setOpenExternalLinks(true); +} + +// The username/timestamp is displayed along with the message body. +void +TimelineItem::generateBody(const QString &userid, const QString &body) +{ + auto sender = userid; + + if (userid.startsWith("@")) { + // TODO: Fix this by using a UserId type. + if (userid.split(":")[0].split("@").size() > 1) + sender = userid.split(":")[0].split("@")[1]; + } + + QFont usernameFont = font_; + usernameFont.setBold(true); + + userName_ = new QLabel(this); + userName_->setFont(usernameFont); + userName_->setText(sender); + + if (body.isEmpty()) + return; + + body_ = new QLabel(this); + body_->setFont(font_); + body_->setWordWrap(true); + body_->setText(QString("<span> %1 </span>").arg(replaceEmoji(body))); + body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + body_->setOpenExternalLinks(true); + body_->setMargin(0); +} + +void +TimelineItem::generateTimestamp(const QDateTime &time) +{ + QFont timestampFont; + timestampFont.setPixelSize(conf::timeline::fonts::timestamp); + + QFontMetrics fm(timestampFont); + int topMargin = QFontMetrics(font_).ascent() - fm.ascent(); + + timestamp_ = new QLabel(this); + timestamp_->setFont(timestampFont); + timestamp_->setText(time.toString("HH:mm")); + timestamp_->setContentsMargins(0, topMargin, 0, 0); + timestamp_->setStyleSheet( + QString("font-size: %1px;").arg(conf::timeline::fonts::timestamp)); +} + +QString +TimelineItem::replaceEmoji(const QString &body) +{ + QString fmtBody = ""; + + QVector<uint> utf32_string = body.toUcs4(); + + for (auto &code : utf32_string) { + // TODO: Be more precise here. + if (code > 9000) + fmtBody += QString("<span style=\"font-family: Emoji " + "One; font-size: %1px\">") + .arg(conf::emojiSize) + + QString::fromUcs4(&code, 1) + "</span>"; + else + fmtBody += QString::fromUcs4(&code, 1); + } + + return fmtBody; +} + +void +TimelineItem::setupAvatarLayout(const QString &userName) +{ + topLayout_->setContentsMargins(conf::timeline::msgMargin, conf::timeline::msgMargin, 0, 0); + + userAvatar_ = new Avatar(this); + userAvatar_->setLetter(QChar(userName[0]).toUpper()); + userAvatar_->setSize(conf::timeline::avatarSize); + + // TODO: The provided user name should be a UserId class + if (userName[0] == '@' && userName.size() > 1) + userAvatar_->setLetter(QChar(userName[1]).toUpper()); + + sideLayout_->addWidget(userAvatar_); + sideLayout_->addStretch(1); + + headerLayout_->addWidget(userName_); + headerLayout_->addWidget(timestamp_, 1); +} + +void +TimelineItem::setupSimpleLayout() +{ + sideLayout_->addWidget(timestamp_); + + // Keep only the time in plain text. + QTextEdit htmlText(timestamp_->text()); + QString plainText = htmlText.toPlainText(); + + timestamp_->adjustSize(); + + // Align the end of the avatar bubble with the end of the timestamp for + // messages with and without avatar. Otherwise their bodies would not be + // aligned. + int tsWidth = timestamp_->fontMetrics().width(plainText); + int offset = std::max(0, conf::timeline::avatarSize - tsWidth); + + int defaultFontHeight = QFontMetrics(font_).ascent(); + + timestamp_->setAlignment(Qt::AlignTop); + timestamp_->setContentsMargins( + offset + 1, defaultFontHeight - timestamp_->fontMetrics().ascent(), 0, 0); + topLayout_->setContentsMargins( + conf::timeline::msgMargin, conf::timeline::msgMargin / 3, 0, 0); +} + +void +TimelineItem::setUserAvatar(const QImage &avatar) +{ + if (userAvatar_ == nullptr) + return; + + userAvatar_->setImage(avatar); +} + +QString +TimelineItem::descriptiveTime(const QDateTime &then) +{ + auto now = QDateTime::currentDateTime(); + + auto days = then.daysTo(now); + + if (days == 0) + return then.toString("HH:mm"); + else if (days < 2) + return QString("Yesterday"); + else if (days < 365) + return then.toString("dd/MM"); + + return then.toString("dd/MM/yy"); +} + +TimelineItem::~TimelineItem() {} + +void +TimelineItem::paintEvent(QPaintEvent *) +{ + QStyleOption opt; + opt.init(this); + QPainter p(this); + style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); +} diff --git a/src/timeline/TimelineView.cc b/src/timeline/TimelineView.cc new file mode 100644
index 00000000..8ccff85a --- /dev/null +++ b/src/timeline/TimelineView.cc
@@ -0,0 +1,561 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +#include <QApplication> +#include <QFileInfo> +#include <QTimer> + +#include "FloatingButton.h" +#include "RoomMessages.h" +#include "ScrollBar.h" +#include "Sync.h" + +#include "timeline/TimelineView.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +static bool +isRedactedEvent(const QJsonObject &event) +{ + if (event.contains("redacted_because")) + return true; + + if (event.contains("unsigned") && + event.value("unsigned").toObject().contains("redacted_because")) + return true; + + return false; +} + +TimelineView::TimelineView(const Timeline &timeline, + QSharedPointer<MatrixClient> client, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} + , client_{client} +{ + init(); + addEvents(timeline); +} + +TimelineView::TimelineView(QSharedPointer<MatrixClient> client, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} + , client_{client} +{ + init(); +} + +void +TimelineView::sliderRangeChanged(int min, int max) +{ + Q_UNUSED(min); + + if (!scroll_area_->verticalScrollBar()->isVisible()) { + scroll_area_->verticalScrollBar()->setValue(max); + return; + } + + // If the scrollbar is close to the bottom and a new message + // is added we move the scrollbar. + if (max - scroll_area_->verticalScrollBar()->value() < SCROLL_BAR_GAP) { + scroll_area_->verticalScrollBar()->setValue(max); + return; + } + + int currentHeight = scroll_widget_->size().height(); + int diff = currentHeight - oldHeight_; + int newPosition = oldPosition_ + diff; + + // Keep the scroll bar to the bottom if it hasn't been activated yet. + if (oldPosition_ == 0 && !scroll_area_->verticalScrollBar()->isVisible()) + newPosition = max; + + if (lastMessageDirection_ == TimelineDirection::Top) + scroll_area_->verticalScrollBar()->setValue(newPosition); +} + +void +TimelineView::fetchHistory() +{ + bool hasEnoughMessages = scroll_area_->verticalScrollBar()->isVisible(); + + if (!hasEnoughMessages && !isTimelineFinished) { + isPaginationInProgress_ = true; + client_->messages(room_id_, prev_batch_token_); + paginationTimer_->start(500); + return; + } + + paginationTimer_->stop(); +} + +void +TimelineView::scrollDown() +{ + int current = scroll_area_->verticalScrollBar()->value(); + int max = scroll_area_->verticalScrollBar()->maximum(); + + // The first time we enter the room move the scroll bar to the bottom. + if (!isInitialized) { + scroll_area_->verticalScrollBar()->setValue(max); + isInitialized = true; + return; + } + + // If the gap is small enough move the scroll bar down. e.g when a new + // message appears. + if (max - current < SCROLL_BAR_GAP) + scroll_area_->verticalScrollBar()->setValue(max); +} + +void +TimelineView::sliderMoved(int position) +{ + if (!scroll_area_->verticalScrollBar()->isVisible()) + return; + + const int maxScroll = scroll_area_->verticalScrollBar()->maximum(); + const int currentScroll = scroll_area_->verticalScrollBar()->value(); + + if (maxScroll - currentScroll > SCROLL_BAR_GAP) { + scrollDownBtn_->show(); + scrollDownBtn_->raise(); + } else { + scrollDownBtn_->hide(); + } + + // The scrollbar is high enough so we can start retrieving old events. + if (position < SCROLL_BAR_GAP) { + if (isTimelineFinished) + return; + + // Prevent user from moving up when there is pagination in + // progress. + // TODO: Keep a map of the event ids to filter out duplicates. + if (isPaginationInProgress_) + return; + + isPaginationInProgress_ = true; + + // FIXME: Maybe move this to TimelineViewManager to remove the + // extra calls? + client_->messages(room_id_, prev_batch_token_); + } +} + +void +TimelineView::addBackwardsEvents(const QString &room_id, const RoomMessages &msgs) +{ + if (room_id_ != room_id) + return; + + if (msgs.chunk().count() == 0) { + isTimelineFinished = true; + return; + } + + isTimelineFinished = false; + QList<TimelineItem *> items; + + // Reset the sender of the first message in the timeline + // cause we're about to insert a new one. + firstSender_.clear(); + + // Parse in reverse order to determine where we should not show sender's + // name. + auto ii = msgs.chunk().size(); + while (ii != 0) { + --ii; + + TimelineItem *item = + parseMessageEvent(msgs.chunk().at(ii).toObject(), TimelineDirection::Top); + + if (item != nullptr) + items.push_back(item); + } + + // Reverse again to render them. + std::reverse(items.begin(), items.end()); + + oldPosition_ = scroll_area_->verticalScrollBar()->value(); + oldHeight_ = scroll_widget_->size().height(); + + for (const auto &item : items) + addTimelineItem(item, TimelineDirection::Top); + + lastMessageDirection_ = TimelineDirection::Top; + + QApplication::processEvents(); + + prev_batch_token_ = msgs.end(); + isPaginationInProgress_ = false; + + // Exclude the top stretch. + if (!msgs.chunk().isEmpty() && scroll_layout_->count() > 1) + notifyForLastEvent(); + + // If this batch is the first being rendered (i.e the first and the last + // events originate from this batch), set the last sender. + if (lastSender_.isEmpty() && !items.isEmpty()) + lastSender_ = items.constFirst()->descriptionMessage().userid; +} + +TimelineItem * +TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection direction) +{ + events::EventType ty = events::extractEventType(event); + + if (ty == events::EventType::RoomMessage) { + events::MessageEventType msg_type = events::extractMessageEventType(event); + + using Emote = events::MessageEvent<msgs::Emote>; + using File = events::MessageEvent<msgs::File>; + using Image = events::MessageEvent<msgs::Image>; + using Notice = events::MessageEvent<msgs::Notice>; + using Text = events::MessageEvent<msgs::Text>; + + if (msg_type == events::MessageEventType::Text) { + return processMessageEvent<Text>(event, direction); + } else if (msg_type == events::MessageEventType::Notice) { + return processMessageEvent<Notice>(event, direction); + } else if (msg_type == events::MessageEventType::Image) { + return processMessageEvent<Image, ImageItem>(event, direction); + } else if (msg_type == events::MessageEventType::Emote) { + return processMessageEvent<Emote>(event, direction); + } else if (msg_type == events::MessageEventType::File) { + return processMessageEvent<File, FileItem>(event, direction); + } else if (msg_type == events::MessageEventType::Unknown) { + // TODO Handle redacted messages. + // Silenced for now. + if (!isRedactedEvent(event)) + qWarning() << "Unknown message type" << event; + + return nullptr; + } + } + + return nullptr; +} + +int +TimelineView::addEvents(const Timeline &timeline) +{ + int message_count = 0; + + QSettings settings; + QString localUser = settings.value("auth/user_id").toString(); + + for (const auto &event : timeline.events()) { + TimelineItem *item = parseMessageEvent(event.toObject(), TimelineDirection::Bottom); + + if (item != nullptr) { + addTimelineItem(item, TimelineDirection::Bottom); + + if (localUser != event.toObject().value("sender").toString()) + message_count += 1; + } + } + + lastMessageDirection_ = TimelineDirection::Bottom; + + QApplication::processEvents(); + + if (isInitialSync) { + prev_batch_token_ = timeline.previousBatch(); + isInitialSync = false; + } + + // Exclude the top stretch. + if (!timeline.events().isEmpty() && scroll_layout_->count() > 1) + notifyForLastEvent(); + + if (isActiveWindow() && isVisible() && timeline.events().size() > 0) + readLastEvent(); + + return message_count; +} + +void +TimelineView::init() +{ + QSettings settings; + local_user_ = settings.value("auth/user_id").toString(); + + QIcon icon; + icon.addFile(":/icons/icons/ui/angle-arrow-down.png"); + scrollDownBtn_ = new FloatingButton(icon, this); + scrollDownBtn_->setBackgroundColor(QColor("#F5F5F5")); + scrollDownBtn_->setForegroundColor(QColor("black")); + scrollDownBtn_->hide(); + + connect(scrollDownBtn_, &QPushButton::clicked, this, [=]() { + const int max = scroll_area_->verticalScrollBar()->maximum(); + scroll_area_->verticalScrollBar()->setValue(max); + }); + top_layout_ = new QVBoxLayout(this); + top_layout_->setSpacing(0); + top_layout_->setMargin(0); + + scroll_area_ = new QScrollArea(this); + scroll_area_->setWidgetResizable(true); + scroll_area_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + scrollbar_ = new ScrollBar(scroll_area_); + scroll_area_->setVerticalScrollBar(scrollbar_); + + scroll_widget_ = new QWidget(this); + + scroll_layout_ = new QVBoxLayout(scroll_widget_); + scroll_layout_->setContentsMargins(15, 0, 15, 15); + scroll_layout_->addStretch(1); + scroll_layout_->setSpacing(0); + scroll_layout_->setObjectName("timelinescrollarea"); + + scroll_area_->setWidget(scroll_widget_); + + top_layout_->addWidget(scroll_area_); + + setLayout(top_layout_); + + paginationTimer_ = new QTimer(this); + connect(paginationTimer_, &QTimer::timeout, this, &TimelineView::fetchHistory); + + connect(client_.data(), + &MatrixClient::messagesRetrieved, + this, + &TimelineView::addBackwardsEvents); + + connect(scroll_area_->verticalScrollBar(), + SIGNAL(valueChanged(int)), + this, + SLOT(sliderMoved(int))); + connect(scroll_area_->verticalScrollBar(), + SIGNAL(rangeChanged(int, int)), + this, + SLOT(sliderRangeChanged(int, int))); +} + +void +TimelineView::updateLastSender(const QString &user_id, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + lastSender_ = user_id; + else + firstSender_ = user_id; +} + +bool +TimelineView::isSenderRendered(const QString &user_id, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + return lastSender_ != user_id; + else + return firstSender_ != user_id; +} + +void +TimelineView::addTimelineItem(TimelineItem *item, TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) + scroll_layout_->addWidget(item); + else + scroll_layout_->insertWidget(1, item); +} + +void +TimelineView::updatePendingMessage(int txn_id, QString event_id) +{ + if (pending_msgs_.head().txn_id == txn_id) { // We haven't received it yet + auto msg = pending_msgs_.dequeue(); + msg.event_id = event_id; + pending_sent_msgs_.append(msg); + } + sendNextPendingMessage(); +} + +void +TimelineView::addUserMessage(matrix::events::MessageEventType ty, const QString &body) +{ + QSettings settings; + auto user_id = settings.value("auth/user_id").toString(); + auto with_sender = lastSender_ != user_id; + + TimelineItem *view_item = new TimelineItem(ty, user_id, body, with_sender, scroll_widget_); + scroll_layout_->addWidget(view_item); + + lastMessageDirection_ = TimelineDirection::Bottom; + + QApplication::processEvents(); + + lastSender_ = user_id; + + int txn_id = client_->incrementTransactionId(); + PendingMessage message(ty, txn_id, body, "", "", view_item); + handleNewUserMessage(message); +} + +void +TimelineView::handleNewUserMessage(PendingMessage msg) +{ + pending_msgs_.enqueue(msg); + if (pending_msgs_.size() == 1 && pending_sent_msgs_.size() == 0) + sendNextPendingMessage(); +} + +void +TimelineView::sendNextPendingMessage() +{ + if (pending_msgs_.size() == 0) + return; + + PendingMessage &m = pending_msgs_.head(); + switch (m.ty) { + case matrix::events::MessageEventType::Image: + case matrix::events::MessageEventType::File: + client_->sendRoomMessage( + m.ty, m.txn_id, room_id_, QFileInfo(m.filename).fileName(), m.body); + break; + default: + client_->sendRoomMessage(m.ty, m.txn_id, room_id_, m.body); + break; + } +} + +void +TimelineView::notifyForLastEvent() +{ + auto lastItem = scroll_layout_->itemAt(scroll_layout_->count() - 1); + auto *lastTimelineItem = qobject_cast<TimelineItem *>(lastItem->widget()); + + if (lastTimelineItem) + emit updateLastTimelineMessage(room_id_, lastTimelineItem->descriptionMessage()); + else + qWarning() << "Cast to TimelineView failed" << room_id_; +} + +bool +TimelineView::isPendingMessage(const QString &txnid, + const QString &sender, + const QString &local_userid) +{ + if (sender != local_userid) + return false; + + for (const auto &msg : pending_msgs_) { + if (QString::number(msg.txn_id) == txnid) + return true; + } + + for (const auto &msg : pending_sent_msgs_) { + if (QString::number(msg.txn_id) == txnid) + return true; + } + + return false; +} + +void +TimelineView::removePendingMessage(const QString &txnid) +{ + for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) { + if (QString::number(it->txn_id) == txnid) { + int index = std::distance(pending_sent_msgs_.begin(), it); + pending_sent_msgs_.removeAt(index); + return; + } + } + for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) { + if (QString::number(it->txn_id) == txnid) { + int index = std::distance(pending_msgs_.begin(), it); + pending_msgs_.removeAt(index); + return; + } + } +} + +void +TimelineView::handleFailedMessage(int txnid) +{ + Q_UNUSED(txnid); + // Note: We do this even if the message has already been echoed. + QTimer::singleShot(500, this, SLOT(sendNextPendingMessage())); +} + +void +TimelineView::paintEvent(QPaintEvent *) +{ + QStyleOption opt; + opt.init(this); + QPainter p(this); + style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); +} + +void +TimelineView::readLastEvent() const +{ + const auto eventId = getLastEventId(); + + if (!eventId.isEmpty()) + client_->readEvent(room_id_, eventId); +} + +QString +TimelineView::getLastEventId() const +{ + auto index = scroll_layout_->count(); + + // Search backwards for the first event that has a valid event id. + while (index > 0) { + --index; + + auto lastItem = scroll_layout_->itemAt(index); + auto *lastTimelineItem = qobject_cast<TimelineItem *>(lastItem->widget()); + + if (lastTimelineItem && !lastTimelineItem->eventId().isEmpty()) + return lastTimelineItem->eventId(); + } + + return QString(""); +} + +void +TimelineView::showEvent(QShowEvent *event) +{ + readLastEvent(); + + QWidget::showEvent(event); +} + +bool +TimelineView::event(QEvent *event) +{ + if (event->type() == QEvent::WindowActivate) { + QTimer::singleShot(1000, this, [=]() { + emit clearUnreadMessageCount(room_id_); + readLastEvent(); + }); + } + + return QWidget::event(event); +} diff --git a/src/timeline/TimelineViewManager.cc b/src/timeline/TimelineViewManager.cc new file mode 100644
index 00000000..39f07639 --- /dev/null +++ b/src/timeline/TimelineViewManager.cc
@@ -0,0 +1,301 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +#include <random> + +#include <QApplication> +#include <QDebug> +#include <QFileInfo> +#include <QSettings> + +#include "MatrixClient.h" +#include "Sync.h" + +#include "timeline/TimelineView.h" +#include "timeline/TimelineViewManager.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" + +TimelineViewManager::TimelineViewManager(QSharedPointer<MatrixClient> client, QWidget *parent) + : QStackedWidget(parent) + , client_(client) +{ + setStyleSheet("border: none;"); + + connect( + client_.data(), &MatrixClient::messageSent, this, &TimelineViewManager::messageSent); + + connect(client_.data(), + &MatrixClient::messageSendFailed, + this, + &TimelineViewManager::messageSendFailed); +} + +TimelineViewManager::~TimelineViewManager() {} + +void +TimelineViewManager::messageSent(const QString &event_id, const QString &roomid, int txn_id) +{ + // We save the latest valid transaction ID for later use. + QSettings settings; + settings.setValue("client/transaction_id", txn_id + 1); + + auto view = views_[roomid]; + view->updatePendingMessage(txn_id, event_id); +} + +void +TimelineViewManager::messageSendFailed(const QString &roomid, int txn_id) +{ + auto view = views_[roomid]; + view->handleFailedMessage(txn_id); +} + +void +TimelineViewManager::queueTextMessage(const QString &msg) +{ + auto room_id = active_room_; + auto view = views_[room_id]; + + view->addUserMessage(matrix::events::MessageEventType::Text, msg); +} + +void +TimelineViewManager::queueEmoteMessage(const QString &msg) +{ + auto room_id = active_room_; + auto view = views_[room_id]; + + view->addUserMessage(matrix::events::MessageEventType::Emote, msg); +} + +void +TimelineViewManager::queueImageMessage(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<ImageItem, matrix::events::MessageEventType::Image>(url, filename); +} + +void +TimelineViewManager::queueFileMessage(const QString &roomid, + const QString &filename, + const QString &url) +{ + if (!views_.contains(roomid)) { + qDebug() << "Cannot send m.file message to a non-managed view"; + return; + } + + auto view = views_[roomid]; + + view->addUserMessage<FileItem, matrix::events::MessageEventType::File>(url, filename); +} + +void +TimelineViewManager::clearAll() +{ + for (auto view : views_) + removeWidget(view.data()); + + views_.clear(); +} + +void +TimelineViewManager::initialize(const Rooms &rooms) +{ + for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); ++it) { + addRoom(it.value(), it.key()); + } +} + +void +TimelineViewManager::initialize(const QList<QString> &rooms) +{ + for (const auto &roomid : rooms) { + addRoom(roomid); + } +} + +void +TimelineViewManager::addRoom(const JoinedRoom &room, const QString &room_id) +{ + // Create a history view with the room events. + TimelineView *view = new TimelineView(room.timeline(), client_, room_id); + views_.insert(room_id, QSharedPointer<TimelineView>(view)); + + connect(view, + &TimelineView::updateLastTimelineMessage, + this, + &TimelineViewManager::updateRoomsLastMessage); + connect(view, + &TimelineView::clearUnreadMessageCount, + this, + &TimelineViewManager::clearRoomMessageCount); + + // Add the view in the widget stack. + addWidget(view); +} + +void +TimelineViewManager::addRoom(const QString &room_id) +{ + // Create a history view without any events. + TimelineView *view = new TimelineView(client_, room_id); + views_.insert(room_id, QSharedPointer<TimelineView>(view)); + + connect(view, + &TimelineView::updateLastTimelineMessage, + this, + &TimelineViewManager::updateRoomsLastMessage); + connect(view, + &TimelineView::clearUnreadMessageCount, + this, + &TimelineViewManager::clearRoomMessageCount); + + // Add the view in the widget stack. + addWidget(view); +} + +void +TimelineViewManager::sync(const Rooms &rooms) +{ + for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); ++it) { + auto roomid = it.key(); + + if (!views_.contains(roomid)) { + qDebug() << "Ignoring event from unknown room" << roomid; + continue; + } + + auto view = views_.value(roomid); + + int msgs_added = view->addEvents(it.value().timeline()); + + if (msgs_added > 0) { + // TODO: When the app window gets active the current + // unread count (if any) should be cleared. + auto isAppActive = QApplication::activeWindow() != nullptr; + + if (roomid != active_room_ || !isAppActive) + emit unreadMessages(roomid, msgs_added); + } + } +} + +void +TimelineViewManager::setHistoryView(const QString &room_id) +{ + if (!views_.contains(room_id)) { + qDebug() << "Room ID from RoomList is not present in ViewManager" << room_id; + return; + } + + active_room_ = room_id; + auto view = views_.value(room_id); + + setCurrentWidget(view.data()); + + view->fetchHistory(); + view->scrollDown(); +} + +QMap<QString, QString> TimelineViewManager::DISPLAY_NAMES; + +QString +TimelineViewManager::chooseRandomColor() +{ + std::random_device random_device; + std::mt19937 engine{random_device()}; + std::uniform_real_distribution<float> dist(0, 1); + + float hue = dist(engine); + float saturation = 0.9; + float value = 0.7; + + int hue_i = hue * 6; + + float f = hue * 6 - hue_i; + + float p = value * (1 - saturation); + float q = value * (1 - f * saturation); + float t = value * (1 - (1 - f) * saturation); + + float r = 0; + float g = 0; + float b = 0; + + if (hue_i == 0) { + r = value; + g = t; + b = p; + } else if (hue_i == 1) { + r = q; + g = value; + b = p; + } else if (hue_i == 2) { + r = p; + g = value; + b = t; + } else if (hue_i == 3) { + r = p; + g = q; + b = value; + } else if (hue_i == 4) { + r = t; + g = p; + b = value; + } else if (hue_i == 5) { + r = value; + g = p; + b = q; + } + + int ri = r * 256; + int gi = g * 256; + int bi = b * 256; + + QColor color(ri, gi, bi); + + return color.name(); +} + +QString +TimelineViewManager::displayName(const QString &userid) +{ + if (DISPLAY_NAMES.contains(userid)) + return DISPLAY_NAMES.value(userid); + + return userid; +} + +bool +TimelineViewManager::hasLoaded() const +{ + for (const auto &view : views_) + if (!view->hasLoaded()) + return false; + + return true; +} diff --git a/src/timeline/widgets/FileItem.cc b/src/timeline/widgets/FileItem.cc new file mode 100644
index 00000000..8d0100c7 --- /dev/null +++ b/src/timeline/widgets/FileItem.cc
@@ -0,0 +1,208 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +#include <QBrush> +#include <QDebug> +#include <QDesktopServices> +#include <QFile> +#include <QFileDialog> +#include <QFileInfo> +#include <QPainter> +#include <QPixmap> + +#include "timeline/widgets/FileItem.h" + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +void +FileItem::init() +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); + + 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); + + connect(client_.data(), &MatrixClient::fileDownloaded, this, &FileItem::fileDownloaded); +} + +FileItem::FileItem(QSharedPointer<MatrixClient> client, + const events::MessageEvent<msgs::File> &event, + QWidget *parent) + : QWidget(parent) + , url_{event.msgContent().url()} + , text_{event.content().body()} + , event_{event} + , client_{client} +{ + readableFileSize_ = calculateFileSize(event.msgContent().info().size); + + init(); +} + +FileItem::FileItem(QSharedPointer<MatrixClient> client, + const QString &url, + const QString &filename, + QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{QFileInfo(filename).fileName()} + , client_{client} +{ + readableFileSize_ = calculateFileSize(QFileInfo(filename).size()); + + init(); +} + +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/timeline/widgets/ImageItem.cc b/src/timeline/widgets/ImageItem.cc new file mode 100644
index 00000000..106fc79b --- /dev/null +++ b/src/timeline/widgets/ImageItem.cc
@@ -0,0 +1,223 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +#include <QBrush> +#include <QDebug> +#include <QDesktopServices> +#include <QFileInfo> +#include <QPainter> +#include <QPixmap> + +#include "dialogs/ImageOverlayDialog.h" +#include "timeline/widgets/ImageItem.h" + +namespace events = matrix::events; +namespace msgs = matrix::events::messages; + +ImageItem::ImageItem(QSharedPointer<MatrixClient> client, + const events::MessageEvent<msgs::Image> &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(); + + 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); + + client_.data()->downloadImage(event.eventId(), url_); + + connect(client_.data(), + SIGNAL(imageDownloaded(const QString &, const QPixmap &)), + this, + 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 +ImageItem::imageDownloaded(const QString &event_id, const QPixmap &img) +{ + if (event_id != event_.eventId()) + return; + + setImage(img); +} + +void +ImageItem::openUrl() +{ + if (url_.toString().isEmpty()) + return; + + if (!QDesktopServices::openUrl(url_)) + qWarning() << "Could not open url" << url_.toString(); +} + +void +ImageItem::scaleImage() +{ + if (image_.isNull()) + return; + + auto width_ratio = (double)max_width_ / (double)image_.width(); + auto height_ratio = (double)max_height_ / (double)image_.height(); + + auto min_aspect_ratio = std::min(width_ratio, height_ratio); + + if (min_aspect_ratio > 1) { + width_ = image_.width(); + height_ = image_.height(); + } else { + width_ = image_.width() * min_aspect_ratio; + height_ = image_.height() * min_aspect_ratio; + } + + setFixedSize(width_, height_); + scaled_image_ = + image_.scaled(width_, height_, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} + +QSize +ImageItem::sizeHint() const +{ + if (image_.isNull()) + return QSize(max_width_, bottom_height_); + + return QSize(width_, height_); +} + +void +ImageItem::setImage(const QPixmap &image) +{ + image_ = image; + scaleImage(); + update(); +} + +void +ImageItem::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) + return; + + if (image_.isNull()) { + openUrl(); + return; + } + + auto point = event->pos(); + + // Click on the text box. + if (QRect(0, height_ - bottom_height_, width_, bottom_height_).contains(point)) { + openUrl(); + } else { + auto image_dialog = new ImageOverlayDialog(image_, this); + image_dialog->show(); + } +} + +void +ImageItem::resizeEvent(QResizeEvent *event) +{ + Q_UNUSED(event); + + scaleImage(); +} + +void +ImageItem::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QFont font("Open Sans"); + font.setPixelSize(12); + + QFontMetrics metrics(font); + int fontHeight = metrics.height(); + + if (image_.isNull()) { + int height = fontHeight + 10; + + QString elidedText = metrics.elidedText(text_, Qt::ElideRight, max_width_ - 10); + + setFixedSize(metrics.width(elidedText), fontHeight + 10); + + painter.setFont(font); + painter.setPen(QPen(QColor(66, 133, 244))); + painter.drawText(QPoint(0, height / 2 + 2), elidedText); + + return; + } + + painter.fillRect(QRect(0, 0, width_, height_), scaled_image_); + + if (underMouse()) { + // Bottom text section + painter.fillRect(QRect(0, height_ - bottom_height_, width_, bottom_height_), + QBrush(QColor(33, 33, 33, 128))); + + QString elidedText = metrics.elidedText(text_, Qt::ElideRight, width_ - 10); + + font.setWeight(80); + painter.setFont(font); + painter.setPen(QPen(QColor("white"))); + painter.drawText(QPoint(5, height_ - fontHeight / 2), elidedText); + } +}