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);
+ }
+}
|