From 0e814da91c8e041897a4c3f7e6e9234bbc7c6f7a Mon Sep 17 00:00:00 2001 From: Konstantinos Sideris Date: Tue, 17 Jul 2018 16:37:25 +0300 Subject: Move all files under src/ --- src/timeline/TimelineItem.cc | 734 ----------------- src/timeline/TimelineItem.cpp | 734 +++++++++++++++++ src/timeline/TimelineItem.h | 380 +++++++++ src/timeline/TimelineView.cc | 1459 ---------------------------------- src/timeline/TimelineView.cpp | 1459 ++++++++++++++++++++++++++++++++++ src/timeline/TimelineView.h | 426 ++++++++++ src/timeline/TimelineViewManager.cc | 318 -------- src/timeline/TimelineViewManager.cpp | 318 ++++++++ src/timeline/TimelineViewManager.h | 94 +++ src/timeline/widgets/AudioItem.cc | 230 ------ src/timeline/widgets/AudioItem.cpp | 230 ++++++ src/timeline/widgets/AudioItem.h | 107 +++ src/timeline/widgets/FileItem.cc | 216 ----- src/timeline/widgets/FileItem.cpp | 216 +++++ src/timeline/widgets/FileItem.h | 82 ++ src/timeline/widgets/ImageItem.cc | 259 ------ src/timeline/widgets/ImageItem.cpp | 259 ++++++ src/timeline/widgets/ImageItem.h | 108 +++ src/timeline/widgets/VideoItem.cc | 66 -- src/timeline/widgets/VideoItem.cpp | 66 ++ src/timeline/widgets/VideoItem.h | 51 ++ 21 files changed, 4530 insertions(+), 3282 deletions(-) delete mode 100644 src/timeline/TimelineItem.cc create mode 100644 src/timeline/TimelineItem.cpp create mode 100644 src/timeline/TimelineItem.h delete mode 100644 src/timeline/TimelineView.cc create mode 100644 src/timeline/TimelineView.cpp create mode 100644 src/timeline/TimelineView.h delete mode 100644 src/timeline/TimelineViewManager.cc create mode 100644 src/timeline/TimelineViewManager.cpp create mode 100644 src/timeline/TimelineViewManager.h delete mode 100644 src/timeline/widgets/AudioItem.cc create mode 100644 src/timeline/widgets/AudioItem.cpp create mode 100644 src/timeline/widgets/AudioItem.h delete mode 100644 src/timeline/widgets/FileItem.cc create mode 100644 src/timeline/widgets/FileItem.cpp create mode 100644 src/timeline/widgets/FileItem.h delete mode 100644 src/timeline/widgets/ImageItem.cc create mode 100644 src/timeline/widgets/ImageItem.cpp create mode 100644 src/timeline/widgets/ImageItem.h delete mode 100644 src/timeline/widgets/VideoItem.cc create mode 100644 src/timeline/widgets/VideoItem.cpp create mode 100644 src/timeline/widgets/VideoItem.h (limited to 'src/timeline') diff --git a/src/timeline/TimelineItem.cc b/src/timeline/TimelineItem.cc deleted file mode 100644 index d756ca26..00000000 --- a/src/timeline/TimelineItem.cc +++ /dev/null @@ -1,734 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include - -#include "Avatar.h" -#include "ChatPage.h" -#include "Config.h" -#include "Logging.hpp" -#include "Olm.hpp" -#include "Painter.h" - -#include "timeline/TimelineItem.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" - -constexpr int MSG_RIGHT_MARGIN = 7; -constexpr int MSG_PADDING = 20; - -StatusIndicator::StatusIndicator(QWidget *parent) - : QWidget(parent) -{ - lockIcon_.addFile(":/icons/icons/ui/lock.png"); - clockIcon_.addFile(":/icons/icons/ui/clock.png"); - checkmarkIcon_.addFile(":/icons/icons/ui/checkmark.png"); -} - -void -StatusIndicator::paintIcon(QPainter &p, QIcon &icon) -{ - auto pixmap = icon.pixmap(width()); - - QPainter painter(&pixmap); - painter.setCompositionMode(QPainter::CompositionMode_SourceIn); - painter.fillRect(pixmap.rect(), p.pen().color()); - - QIcon(pixmap).paint(&p, rect(), Qt::AlignCenter, QIcon::Normal); -} - -void -StatusIndicator::paintEvent(QPaintEvent *) -{ - if (state_ == StatusIndicatorState::Empty) - return; - - Painter p(this); - PainterHighQualityEnabler hq(p); - - p.setPen(iconColor_); - - switch (state_) { - case StatusIndicatorState::Sent: { - paintIcon(p, clockIcon_); - break; - } - case StatusIndicatorState::Encrypted: - paintIcon(p, lockIcon_); - break; - case StatusIndicatorState::Received: { - paintIcon(p, checkmarkIcon_); - break; - } - case StatusIndicatorState::Empty: - break; - } -} - -void -StatusIndicator::setState(StatusIndicatorState state) -{ - state_ = state; - update(); -} - -void -TimelineItem::adjustMessageLayoutForWidget() -{ - messageLayout_->addLayout(widgetLayout_, 1); - messageLayout_->addWidget(statusIndicator_); - messageLayout_->addWidget(timestamp_); - - messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); - messageLayout_->setAlignment(timestamp_, Qt::AlignTop); - - mainLayout_->addLayout(messageLayout_); -} - -void -TimelineItem::adjustMessageLayout() -{ - messageLayout_->addWidget(body_, 1); - messageLayout_->addWidget(statusIndicator_); - messageLayout_->addWidget(timestamp_); - - messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); - messageLayout_->setAlignment(timestamp_, Qt::AlignTop); - - mainLayout_->addLayout(messageLayout_); -} - -void -TimelineItem::init() -{ - userAvatar_ = nullptr; - timestamp_ = nullptr; - userName_ = nullptr; - body_ = nullptr; - - font_.setPixelSize(conf::fontSize); - usernameFont_ = font_; - usernameFont_.setWeight(60); - - QFontMetrics fm(font_); - - contextMenu_ = new QMenu(this); - showReadReceipts_ = new QAction("Read receipts", this); - markAsRead_ = new QAction("Mark as read", this); - redactMsg_ = new QAction("Redact message", this); - contextMenu_->addAction(showReadReceipts_); - contextMenu_->addAction(markAsRead_); - contextMenu_->addAction(redactMsg_); - - connect(showReadReceipts_, &QAction::triggered, this, [this]() { - if (!event_id_.isEmpty()) - ChatPage::instance()->showReadReceipts(event_id_); - }); - - connect(this, &TimelineItem::eventRedacted, this, [this](const QString &event_id) { - emit ChatPage::instance()->removeTimelineEvent(room_id_, event_id); - }); - connect(this, &TimelineItem::redactionFailed, this, [](const QString &msg) { - emit ChatPage::instance()->showNotification(msg); - }); - connect(redactMsg_, &QAction::triggered, this, [this]() { - if (!event_id_.isEmpty()) - http::client()->redact_event( - room_id_.toStdString(), - event_id_.toStdString(), - [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { - if (err) { - emit redactionFailed(tr("Message redaction failed: %1") - .arg(QString::fromStdString( - err->matrix_error.error))); - return; - } - - emit eventRedacted(event_id_); - }); - }); - - connect(markAsRead_, &QAction::triggered, this, [this]() { sendReadReceipt(); }); - - topLayout_ = new QHBoxLayout(this); - mainLayout_ = new QVBoxLayout; - messageLayout_ = new QHBoxLayout; - messageLayout_->setContentsMargins(0, 0, MSG_RIGHT_MARGIN, 0); - messageLayout_->setSpacing(MSG_PADDING); - - topLayout_->setContentsMargins( - conf::timeline::msgLeftMargin, conf::timeline::msgTopMargin, 0, 0); - topLayout_->setSpacing(0); - topLayout_->addLayout(mainLayout_, 1); - - mainLayout_->setContentsMargins(conf::timeline::headerLeftMargin, 0, 0, 0); - mainLayout_->setSpacing(0); - - QFont timestampFont; - timestampFont.setPixelSize(conf::timeline::fonts::indicator); - QFontMetrics tsFm(timestampFont); - - statusIndicator_ = new StatusIndicator(this); - statusIndicator_->setFixedWidth(tsFm.height() - tsFm.leading()); - statusIndicator_->setFixedHeight(tsFm.height() - tsFm.leading()); -} - -/* - * For messages created locally. - */ -TimelineItem::TimelineItem(mtx::events::MessageType ty, - const QString &userid, - QString body, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - auto displayName = Cache::displayName(room_id_, userid); - auto timestamp = QDateTime::currentDateTime(); - - if (ty == mtx::events::MessageType::Emote) { - body = QString("* %1 %2").arg(displayName).arg(body); - descriptionMsg_ = {"", userid, body, utils::descriptiveTime(timestamp), timestamp}; - } else { - descriptionMsg_ = { - "You: ", userid, body, utils::descriptiveTime(timestamp), timestamp}; - } - - body = body.toHtmlEscaped(); - body.replace(conf::strings::url_regex, conf::strings::url_html); - body.replace("\n", "
"); - generateTimestamp(timestamp); - - if (withSender) { - generateBody(userid, displayName, body); - setupAvatarLayout(displayName); - - AvatarProvider::resolve( - room_id_, userid, this, [this](const QImage &img) { setUserAvatar(img); }); - } else { - generateBody(body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -TimelineItem::TimelineItem(ImageItem *image, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(image, userid, withSender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(FileItem *file, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(file, userid, withSender); -} - -TimelineItem::TimelineItem(AudioItem *audio, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(audio, userid, withSender); -} - -TimelineItem::TimelineItem(VideoItem *video, - const QString &userid, - bool withSender, - const QString &room_id, - QWidget *parent) - : QWidget{parent} - , room_id_{room_id} -{ - init(); - - setupLocalWidgetLayout(video, userid, withSender); -} - -TimelineItem::TimelineItem(ImageItem *image, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout, ImageItem>( - image, event, with_sender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(StickerItem *image, - const mtx::events::Sticker &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout(image, event, with_sender); - - addSaveImageAction(image); -} - -TimelineItem::TimelineItem(FileItem *file, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout, FileItem>( - file, event, with_sender); -} - -TimelineItem::TimelineItem(AudioItem *audio, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout, AudioItem>( - audio, event, with_sender); -} - -TimelineItem::TimelineItem(VideoItem *video, - const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - setupWidgetLayout, VideoItem>( - video, event, with_sender); -} - -/* - * Used to display remote notice messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - const auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto body = QString::fromStdString(event.content.body).trimmed().toHtmlEscaped(); - - descriptionMsg_ = {Cache::displayName(room_id_, sender), - sender, - " sent a notification", - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - body.replace(conf::strings::url_regex, conf::strings::url_html); - body.replace("\n", "
"); - body = "" + body + ""; - - if (with_sender) { - auto displayName = Cache::displayName(room_id_, sender); - - generateBody(sender, displayName, body); - setupAvatarLayout(displayName); - - AvatarProvider::resolve( - room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); - } else { - generateBody(body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -/* - * Used to display remote emote messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - auto body = QString::fromStdString(event.content.body).trimmed(); - auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto displayName = Cache::displayName(room_id_, sender); - auto emoteMsg = QString("* %1 %2").arg(displayName).arg(body); - - descriptionMsg_ = {"", sender, emoteMsg, utils::descriptiveTime(timestamp), timestamp}; - - generateTimestamp(timestamp); - emoteMsg = emoteMsg.toHtmlEscaped(); - emoteMsg.replace(conf::strings::url_regex, conf::strings::url_html); - emoteMsg.replace("\n", "
"); - - if (with_sender) { - generateBody(sender, displayName, emoteMsg); - setupAvatarLayout(displayName); - - AvatarProvider::resolve( - room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); - } else { - generateBody(emoteMsg); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -/* - * Used to display remote text messages. - */ -TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, - bool with_sender, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addReplyAction(); - - event_id_ = QString::fromStdString(event.event_id); - const auto sender = QString::fromStdString(event.sender); - - auto body = QString::fromStdString(event.content.body).trimmed(); - auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); - auto displayName = Cache::displayName(room_id_, sender); - - QSettings settings; - descriptionMsg_ = {sender == settings.value("auth/user_id") ? "You" : displayName, - sender, - QString(": %1").arg(body), - utils::descriptiveTime(timestamp), - timestamp}; - - generateTimestamp(timestamp); - - body = body.toHtmlEscaped(); - body.replace(conf::strings::url_regex, conf::strings::url_html); - body.replace("\n", "
"); - - if (with_sender) { - generateBody(sender, displayName, body); - setupAvatarLayout(displayName); - - AvatarProvider::resolve( - room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); - } else { - generateBody(body); - setupSimpleLayout(); - } - - adjustMessageLayout(); -} - -void -TimelineItem::markSent() -{ - statusIndicator_->setState(StatusIndicatorState::Sent); -} - -void -TimelineItem::markReceived(bool isEncrypted) -{ - isReceived_ = true; - - if (isEncrypted) - statusIndicator_->setState(StatusIndicatorState::Encrypted); - else - statusIndicator_->setState(StatusIndicatorState::Received); - - sendReadReceipt(); -} - -// Only the body is displayed. -void -TimelineItem::generateBody(const QString &body) -{ - if (body.isEmpty()) - return; - - QString content("%1"); - - body_ = new TextLabel(content.arg(replaceEmoji(body)), this); - body_->setFont(font_); - body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); -} - -// The username/timestamp is displayed along with the message body. -void -TimelineItem::generateBody(const QString &user_id, const QString &displayname, const QString &body) -{ - auto sender = displayname; - - if (displayname.startsWith("@")) { - // TODO: Fix this by using a UserId type. - if (displayname.split(":")[0].split("@").size() > 1) - sender = displayname.split(":")[0].split("@")[1]; - } - - QFontMetrics fm(usernameFont_); - - userName_ = new QLabel(this); - userName_->setFont(usernameFont_); - userName_->setText(fm.elidedText(sender, Qt::ElideRight, 500)); - userName_->setToolTip(user_id); - userName_->setToolTipDuration(1500); - userName_->setAttribute(Qt::WA_Hover); - userName_->setAlignment(Qt::AlignLeft); - userName_->setFixedWidth(QFontMetrics(userName_->font()).width(userName_->text())); - - auto filter = new UserProfileFilter(user_id, userName_); - userName_->installEventFilter(filter); - - connect(filter, &UserProfileFilter::hoverOn, this, [this]() { - QFont f = userName_->font(); - f.setUnderline(true); - userName_->setCursor(Qt::PointingHandCursor); - userName_->setFont(f); - }); - - connect(filter, &UserProfileFilter::hoverOff, this, [this]() { - QFont f = userName_->font(); - f.setUnderline(false); - userName_->setCursor(Qt::ArrowCursor); - userName_->setFont(f); - }); - - generateBody(body); -} - -void -TimelineItem::generateTimestamp(const QDateTime &time) -{ - QFont timestampFont; - timestampFont.setPixelSize(conf::timeline::fonts::timestamp); - - timestamp_ = new QLabel(this); - timestamp_->setFont(timestampFont); - timestamp_->setText( - QString(" %1 ").arg(time.toString("HH:mm"))); -} - -QString -TimelineItem::replaceEmoji(const QString &body) -{ - QString fmtBody = ""; - - QVector utf32_string = body.toUcs4(); - - for (auto &code : utf32_string) { - // TODO: Be more precise here. - if (code > 9000) - fmtBody += QString("") - .arg(conf::emojiSize) + - QString::fromUcs4(&code, 1) + ""; - else - fmtBody += QString::fromUcs4(&code, 1); - } - - return fmtBody; -} - -void -TimelineItem::setupAvatarLayout(const QString &userName) -{ - topLayout_->setContentsMargins( - conf::timeline::msgLeftMargin, conf::timeline::msgAvatarTopMargin, 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()); - - topLayout_->insertWidget(0, userAvatar_); - topLayout_->setAlignment(userAvatar_, Qt::AlignTop); - - if (userName_) - mainLayout_->insertWidget(0, userName_); -} - -void -TimelineItem::setupSimpleLayout() -{ - topLayout_->setContentsMargins(conf::timeline::msgLeftMargin + conf::timeline::avatarSize + - 2, - conf::timeline::msgTopMargin, - 0, - 0); -} - -void -TimelineItem::setUserAvatar(const QImage &avatar) -{ - if (userAvatar_ == nullptr) - return; - - userAvatar_->setImage(avatar); -} - -void -TimelineItem::contextMenuEvent(QContextMenuEvent *event) -{ - if (contextMenu_) - contextMenu_->exec(event->globalPos()); -} - -void -TimelineItem::paintEvent(QPaintEvent *) -{ - QStyleOption opt; - opt.init(this); - QPainter p(this); - style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); -} - -void -TimelineItem::addSaveImageAction(ImageItem *image) -{ - if (contextMenu_) { - auto saveImage = new QAction("Save image", this); - contextMenu_->addAction(saveImage); - - connect(saveImage, &QAction::triggered, image, &ImageItem::saveAs); - } -} - -void -TimelineItem::addReplyAction() -{ - if (contextMenu_) { - auto replyAction = new QAction("Reply", this); - contextMenu_->addAction(replyAction); - - connect(replyAction, &QAction::triggered, this, [this]() { - if (!body_) - return; - - emit ChatPage::instance()->messageReply( - Cache::displayName(room_id_, descriptionMsg_.userid), - body_->toPlainText()); - }); - } -} - -void -TimelineItem::addKeyRequestAction() -{ - if (contextMenu_) { - auto requestKeys = new QAction("Request encryption keys", this); - contextMenu_->addAction(requestKeys); - - connect(requestKeys, &QAction::triggered, this, [this]() { - olm::request_keys(room_id_.toStdString(), event_id_.toStdString()); - }); - } -} - -void -TimelineItem::addAvatar() -{ - if (userAvatar_) - return; - - // TODO: should be replaced with the proper event struct. - auto userid = descriptionMsg_.userid; - auto displayName = Cache::displayName(room_id_, userid); - - QFontMetrics fm(usernameFont_); - userName_ = new QLabel(this); - userName_->setFont(usernameFont_); - userName_->setText(fm.elidedText(displayName, Qt::ElideRight, 500)); - - setupAvatarLayout(displayName); - - AvatarProvider::resolve( - room_id_, userid, this, [this](const QImage &img) { setUserAvatar(img); }); -} - -void -TimelineItem::sendReadReceipt() const -{ - if (!event_id_.isEmpty()) - http::client()->read_event(room_id_.toStdString(), - event_id_.toStdString(), - [this](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to read_event ({}, {})", - room_id_.toStdString(), - event_id_.toStdString()); - } - }); -} diff --git a/src/timeline/TimelineItem.cpp b/src/timeline/TimelineItem.cpp new file mode 100644 index 00000000..88ab1963 --- /dev/null +++ b/src/timeline/TimelineItem.cpp @@ -0,0 +1,734 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include + +#include "ChatPage.h" +#include "Config.h" +#include "Logging.h" +#include "Olm.h" +#include "ui/Avatar.h" +#include "ui/Painter.h" + +#include "timeline/TimelineItem.h" +#include "timeline/widgets/AudioItem.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" +#include "timeline/widgets/VideoItem.h" + +constexpr int MSG_RIGHT_MARGIN = 7; +constexpr int MSG_PADDING = 20; + +StatusIndicator::StatusIndicator(QWidget *parent) + : QWidget(parent) +{ + lockIcon_.addFile(":/icons/icons/ui/lock.png"); + clockIcon_.addFile(":/icons/icons/ui/clock.png"); + checkmarkIcon_.addFile(":/icons/icons/ui/checkmark.png"); +} + +void +StatusIndicator::paintIcon(QPainter &p, QIcon &icon) +{ + auto pixmap = icon.pixmap(width()); + + QPainter painter(&pixmap); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(pixmap.rect(), p.pen().color()); + + QIcon(pixmap).paint(&p, rect(), Qt::AlignCenter, QIcon::Normal); +} + +void +StatusIndicator::paintEvent(QPaintEvent *) +{ + if (state_ == StatusIndicatorState::Empty) + return; + + Painter p(this); + PainterHighQualityEnabler hq(p); + + p.setPen(iconColor_); + + switch (state_) { + case StatusIndicatorState::Sent: { + paintIcon(p, clockIcon_); + break; + } + case StatusIndicatorState::Encrypted: + paintIcon(p, lockIcon_); + break; + case StatusIndicatorState::Received: { + paintIcon(p, checkmarkIcon_); + break; + } + case StatusIndicatorState::Empty: + break; + } +} + +void +StatusIndicator::setState(StatusIndicatorState state) +{ + state_ = state; + update(); +} + +void +TimelineItem::adjustMessageLayoutForWidget() +{ + messageLayout_->addLayout(widgetLayout_, 1); + messageLayout_->addWidget(statusIndicator_); + messageLayout_->addWidget(timestamp_); + + messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); + messageLayout_->setAlignment(timestamp_, Qt::AlignTop); + + mainLayout_->addLayout(messageLayout_); +} + +void +TimelineItem::adjustMessageLayout() +{ + messageLayout_->addWidget(body_, 1); + messageLayout_->addWidget(statusIndicator_); + messageLayout_->addWidget(timestamp_); + + messageLayout_->setAlignment(statusIndicator_, Qt::AlignTop); + messageLayout_->setAlignment(timestamp_, Qt::AlignTop); + + mainLayout_->addLayout(messageLayout_); +} + +void +TimelineItem::init() +{ + userAvatar_ = nullptr; + timestamp_ = nullptr; + userName_ = nullptr; + body_ = nullptr; + + font_.setPixelSize(conf::fontSize); + usernameFont_ = font_; + usernameFont_.setWeight(60); + + QFontMetrics fm(font_); + + contextMenu_ = new QMenu(this); + showReadReceipts_ = new QAction("Read receipts", this); + markAsRead_ = new QAction("Mark as read", this); + redactMsg_ = new QAction("Redact message", this); + contextMenu_->addAction(showReadReceipts_); + contextMenu_->addAction(markAsRead_); + contextMenu_->addAction(redactMsg_); + + connect(showReadReceipts_, &QAction::triggered, this, [this]() { + if (!event_id_.isEmpty()) + ChatPage::instance()->showReadReceipts(event_id_); + }); + + connect(this, &TimelineItem::eventRedacted, this, [this](const QString &event_id) { + emit ChatPage::instance()->removeTimelineEvent(room_id_, event_id); + }); + connect(this, &TimelineItem::redactionFailed, this, [](const QString &msg) { + emit ChatPage::instance()->showNotification(msg); + }); + connect(redactMsg_, &QAction::triggered, this, [this]() { + if (!event_id_.isEmpty()) + http::client()->redact_event( + room_id_.toStdString(), + event_id_.toStdString(), + [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) { + emit redactionFailed(tr("Message redaction failed: %1") + .arg(QString::fromStdString( + err->matrix_error.error))); + return; + } + + emit eventRedacted(event_id_); + }); + }); + + connect(markAsRead_, &QAction::triggered, this, [this]() { sendReadReceipt(); }); + + topLayout_ = new QHBoxLayout(this); + mainLayout_ = new QVBoxLayout; + messageLayout_ = new QHBoxLayout; + messageLayout_->setContentsMargins(0, 0, MSG_RIGHT_MARGIN, 0); + messageLayout_->setSpacing(MSG_PADDING); + + topLayout_->setContentsMargins( + conf::timeline::msgLeftMargin, conf::timeline::msgTopMargin, 0, 0); + topLayout_->setSpacing(0); + topLayout_->addLayout(mainLayout_, 1); + + mainLayout_->setContentsMargins(conf::timeline::headerLeftMargin, 0, 0, 0); + mainLayout_->setSpacing(0); + + QFont timestampFont; + timestampFont.setPixelSize(conf::timeline::fonts::indicator); + QFontMetrics tsFm(timestampFont); + + statusIndicator_ = new StatusIndicator(this); + statusIndicator_->setFixedWidth(tsFm.height() - tsFm.leading()); + statusIndicator_->setFixedHeight(tsFm.height() - tsFm.leading()); +} + +/* + * For messages created locally. + */ +TimelineItem::TimelineItem(mtx::events::MessageType ty, + const QString &userid, + QString body, + bool withSender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + addReplyAction(); + + auto displayName = Cache::displayName(room_id_, userid); + auto timestamp = QDateTime::currentDateTime(); + + if (ty == mtx::events::MessageType::Emote) { + body = QString("* %1 %2").arg(displayName).arg(body); + descriptionMsg_ = {"", userid, body, utils::descriptiveTime(timestamp), timestamp}; + } else { + descriptionMsg_ = { + "You: ", userid, body, utils::descriptiveTime(timestamp), timestamp}; + } + + body = body.toHtmlEscaped(); + body.replace(conf::strings::url_regex, conf::strings::url_html); + body.replace("\n", "
"); + generateTimestamp(timestamp); + + if (withSender) { + generateBody(userid, displayName, body); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, userid, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + generateBody(body); + setupSimpleLayout(); + } + + adjustMessageLayout(); +} + +TimelineItem::TimelineItem(ImageItem *image, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent) + : QWidget{parent} + , room_id_{room_id} +{ + init(); + + setupLocalWidgetLayout(image, userid, withSender); + + addSaveImageAction(image); +} + +TimelineItem::TimelineItem(FileItem *file, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent) + : QWidget{parent} + , room_id_{room_id} +{ + init(); + + setupLocalWidgetLayout(file, userid, withSender); +} + +TimelineItem::TimelineItem(AudioItem *audio, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent) + : QWidget{parent} + , room_id_{room_id} +{ + init(); + + setupLocalWidgetLayout(audio, userid, withSender); +} + +TimelineItem::TimelineItem(VideoItem *video, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent) + : QWidget{parent} + , room_id_{room_id} +{ + init(); + + setupLocalWidgetLayout(video, userid, withSender); +} + +TimelineItem::TimelineItem(ImageItem *image, + const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + setupWidgetLayout, ImageItem>( + image, event, with_sender); + + addSaveImageAction(image); +} + +TimelineItem::TimelineItem(StickerItem *image, + const mtx::events::Sticker &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + setupWidgetLayout(image, event, with_sender); + + addSaveImageAction(image); +} + +TimelineItem::TimelineItem(FileItem *file, + const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + setupWidgetLayout, FileItem>( + file, event, with_sender); +} + +TimelineItem::TimelineItem(AudioItem *audio, + const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + setupWidgetLayout, AudioItem>( + audio, event, with_sender); +} + +TimelineItem::TimelineItem(VideoItem *video, + const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + setupWidgetLayout, VideoItem>( + video, event, with_sender); +} + +/* + * Used to display remote notice messages. + */ +TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + addReplyAction(); + + event_id_ = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + const auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); + auto body = QString::fromStdString(event.content.body).trimmed().toHtmlEscaped(); + + descriptionMsg_ = {Cache::displayName(room_id_, sender), + sender, + " sent a notification", + utils::descriptiveTime(timestamp), + timestamp}; + + generateTimestamp(timestamp); + + body.replace(conf::strings::url_regex, conf::strings::url_html); + body.replace("\n", "
"); + body = "" + body + ""; + + if (with_sender) { + auto displayName = Cache::displayName(room_id_, sender); + + generateBody(sender, displayName, body); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + generateBody(body); + setupSimpleLayout(); + } + + adjustMessageLayout(); +} + +/* + * Used to display remote emote messages. + */ +TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + addReplyAction(); + + event_id_ = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + + auto body = QString::fromStdString(event.content.body).trimmed(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); + auto displayName = Cache::displayName(room_id_, sender); + auto emoteMsg = QString("* %1 %2").arg(displayName).arg(body); + + descriptionMsg_ = {"", sender, emoteMsg, utils::descriptiveTime(timestamp), timestamp}; + + generateTimestamp(timestamp); + emoteMsg = emoteMsg.toHtmlEscaped(); + emoteMsg.replace(conf::strings::url_regex, conf::strings::url_html); + emoteMsg.replace("\n", "
"); + + if (with_sender) { + generateBody(sender, displayName, emoteMsg); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + generateBody(emoteMsg); + setupSimpleLayout(); + } + + adjustMessageLayout(); +} + +/* + * Used to display remote text messages. + */ +TimelineItem::TimelineItem(const mtx::events::RoomEvent &event, + bool with_sender, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + addReplyAction(); + + event_id_ = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + + auto body = QString::fromStdString(event.content.body).trimmed(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); + auto displayName = Cache::displayName(room_id_, sender); + + QSettings settings; + descriptionMsg_ = {sender == settings.value("auth/user_id") ? "You" : displayName, + sender, + QString(": %1").arg(body), + utils::descriptiveTime(timestamp), + timestamp}; + + generateTimestamp(timestamp); + + body = body.toHtmlEscaped(); + body.replace(conf::strings::url_regex, conf::strings::url_html); + body.replace("\n", "
"); + + if (with_sender) { + generateBody(sender, displayName, body); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + generateBody(body); + setupSimpleLayout(); + } + + adjustMessageLayout(); +} + +void +TimelineItem::markSent() +{ + statusIndicator_->setState(StatusIndicatorState::Sent); +} + +void +TimelineItem::markReceived(bool isEncrypted) +{ + isReceived_ = true; + + if (isEncrypted) + statusIndicator_->setState(StatusIndicatorState::Encrypted); + else + statusIndicator_->setState(StatusIndicatorState::Received); + + sendReadReceipt(); +} + +// Only the body is displayed. +void +TimelineItem::generateBody(const QString &body) +{ + if (body.isEmpty()) + return; + + QString content("%1"); + + body_ = new TextLabel(content.arg(replaceEmoji(body)), this); + body_->setFont(font_); + body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); +} + +// The username/timestamp is displayed along with the message body. +void +TimelineItem::generateBody(const QString &user_id, const QString &displayname, const QString &body) +{ + auto sender = displayname; + + if (displayname.startsWith("@")) { + // TODO: Fix this by using a UserId type. + if (displayname.split(":")[0].split("@").size() > 1) + sender = displayname.split(":")[0].split("@")[1]; + } + + QFontMetrics fm(usernameFont_); + + userName_ = new QLabel(this); + userName_->setFont(usernameFont_); + userName_->setText(fm.elidedText(sender, Qt::ElideRight, 500)); + userName_->setToolTip(user_id); + userName_->setToolTipDuration(1500); + userName_->setAttribute(Qt::WA_Hover); + userName_->setAlignment(Qt::AlignLeft); + userName_->setFixedWidth(QFontMetrics(userName_->font()).width(userName_->text())); + + auto filter = new UserProfileFilter(user_id, userName_); + userName_->installEventFilter(filter); + + connect(filter, &UserProfileFilter::hoverOn, this, [this]() { + QFont f = userName_->font(); + f.setUnderline(true); + userName_->setCursor(Qt::PointingHandCursor); + userName_->setFont(f); + }); + + connect(filter, &UserProfileFilter::hoverOff, this, [this]() { + QFont f = userName_->font(); + f.setUnderline(false); + userName_->setCursor(Qt::ArrowCursor); + userName_->setFont(f); + }); + + generateBody(body); +} + +void +TimelineItem::generateTimestamp(const QDateTime &time) +{ + QFont timestampFont; + timestampFont.setPixelSize(conf::timeline::fonts::timestamp); + + timestamp_ = new QLabel(this); + timestamp_->setFont(timestampFont); + timestamp_->setText( + QString(" %1 ").arg(time.toString("HH:mm"))); +} + +QString +TimelineItem::replaceEmoji(const QString &body) +{ + QString fmtBody = ""; + + QVector utf32_string = body.toUcs4(); + + for (auto &code : utf32_string) { + // TODO: Be more precise here. + if (code > 9000) + fmtBody += QString("") + .arg(conf::emojiSize) + + QString::fromUcs4(&code, 1) + ""; + else + fmtBody += QString::fromUcs4(&code, 1); + } + + return fmtBody; +} + +void +TimelineItem::setupAvatarLayout(const QString &userName) +{ + topLayout_->setContentsMargins( + conf::timeline::msgLeftMargin, conf::timeline::msgAvatarTopMargin, 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()); + + topLayout_->insertWidget(0, userAvatar_); + topLayout_->setAlignment(userAvatar_, Qt::AlignTop); + + if (userName_) + mainLayout_->insertWidget(0, userName_); +} + +void +TimelineItem::setupSimpleLayout() +{ + topLayout_->setContentsMargins(conf::timeline::msgLeftMargin + conf::timeline::avatarSize + + 2, + conf::timeline::msgTopMargin, + 0, + 0); +} + +void +TimelineItem::setUserAvatar(const QImage &avatar) +{ + if (userAvatar_ == nullptr) + return; + + userAvatar_->setImage(avatar); +} + +void +TimelineItem::contextMenuEvent(QContextMenuEvent *event) +{ + if (contextMenu_) + contextMenu_->exec(event->globalPos()); +} + +void +TimelineItem::paintEvent(QPaintEvent *) +{ + QStyleOption opt; + opt.init(this); + QPainter p(this); + style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); +} + +void +TimelineItem::addSaveImageAction(ImageItem *image) +{ + if (contextMenu_) { + auto saveImage = new QAction("Save image", this); + contextMenu_->addAction(saveImage); + + connect(saveImage, &QAction::triggered, image, &ImageItem::saveAs); + } +} + +void +TimelineItem::addReplyAction() +{ + if (contextMenu_) { + auto replyAction = new QAction("Reply", this); + contextMenu_->addAction(replyAction); + + connect(replyAction, &QAction::triggered, this, [this]() { + if (!body_) + return; + + emit ChatPage::instance()->messageReply( + Cache::displayName(room_id_, descriptionMsg_.userid), + body_->toPlainText()); + }); + } +} + +void +TimelineItem::addKeyRequestAction() +{ + if (contextMenu_) { + auto requestKeys = new QAction("Request encryption keys", this); + contextMenu_->addAction(requestKeys); + + connect(requestKeys, &QAction::triggered, this, [this]() { + olm::request_keys(room_id_.toStdString(), event_id_.toStdString()); + }); + } +} + +void +TimelineItem::addAvatar() +{ + if (userAvatar_) + return; + + // TODO: should be replaced with the proper event struct. + auto userid = descriptionMsg_.userid; + auto displayName = Cache::displayName(room_id_, userid); + + QFontMetrics fm(usernameFont_); + userName_ = new QLabel(this); + userName_->setFont(usernameFont_); + userName_->setText(fm.elidedText(displayName, Qt::ElideRight, 500)); + + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, userid, this, [this](const QImage &img) { setUserAvatar(img); }); +} + +void +TimelineItem::sendReadReceipt() const +{ + if (!event_id_.isEmpty()) + http::client()->read_event(room_id_.toStdString(), + event_id_.toStdString(), + [this](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn( + "failed to read_event ({}, {})", + room_id_.toStdString(), + event_id_.toStdString()); + } + }); +} diff --git a/src/timeline/TimelineItem.h b/src/timeline/TimelineItem.h new file mode 100644 index 00000000..d3cab0a0 --- /dev/null +++ b/src/timeline/TimelineItem.h @@ -0,0 +1,380 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AvatarProvider.h" +#include "RoomInfoListItem.h" +#include "Utils.h" + +#include "Cache.h" +#include "MatrixClient.h" + +class ImageItem; +class StickerItem; +class AudioItem; +class VideoItem; +class FileItem; +class Avatar; + +enum class StatusIndicatorState +{ + //! The encrypted message was received by the server. + Encrypted, + //! The plaintext message was received by the server. + Received, + //! The client sent the message. Not yet received. + Sent, + //! When the message is loaded from cache or backfill. + Empty, +}; + +//! +//! Used to notify the user about the status of a message. +//! +class StatusIndicator : public QWidget +{ + Q_OBJECT + +public: + explicit StatusIndicator(QWidget *parent); + void setState(StatusIndicatorState state); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + void paintIcon(QPainter &p, QIcon &icon); + + QIcon lockIcon_; + QIcon clockIcon_; + QIcon checkmarkIcon_; + + QColor iconColor_ = QColor("#999"); + + StatusIndicatorState state_ = StatusIndicatorState::Empty; + + static constexpr int MaxWidth = 24; +}; + +class TextLabel : public QTextBrowser +{ + Q_OBJECT + +public: + TextLabel(const QString &text, QWidget *parent = 0) + : QTextBrowser(parent) + { + setText(text); + setOpenExternalLinks(true); + + // Make it look and feel like an ordinary label. + setReadOnly(true); + setFrameStyle(QFrame::NoFrame); + QPalette pal = palette(); + pal.setColor(QPalette::Base, Qt::transparent); + setPalette(pal); + + // Wrap anywhere but prefer words, adjust minimum height on the fly. + setLineWrapMode(QTextEdit::WidgetWidth); + setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + connect(document()->documentLayout(), + &QAbstractTextDocumentLayout::documentSizeChanged, + this, + &TextLabel::adjustHeight); + document()->setDocumentMargin(0); + + setFixedHeight(20); + } + + void wheelEvent(QWheelEvent *event) override { event->ignore(); } + +private slots: + void adjustHeight(const QSizeF &size) { setFixedHeight(size.height()); } +}; + +class UserProfileFilter : public QObject +{ + Q_OBJECT + +public: + explicit UserProfileFilter(const QString &user_id, QLabel *parent) + : QObject(parent) + , user_id_{user_id} + {} + +signals: + void hoverOff(); + void hoverOn(); + +protected: + bool eventFilter(QObject *obj, QEvent *event) + { + if (event->type() == QEvent::MouseButtonRelease) { + // QMouseEvent *mouseEvent = static_cast(event); + // TODO: Open user profile + return true; + } else if (event->type() == QEvent::HoverLeave) { + emit hoverOff(); + return true; + } else if (event->type() == QEvent::HoverEnter) { + emit hoverOn(); + return true; + } + + return QObject::eventFilter(obj, event); + } + +private: + QString user_id_; +}; + +class TimelineItem : public QWidget +{ + Q_OBJECT +public: + TimelineItem(const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent = 0); + TimelineItem(const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent = 0); + TimelineItem(const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent = 0); + + // For local messages. + // m.text & m.emote + TimelineItem(mtx::events::MessageType ty, + const QString &userid, + QString body, + bool withSender, + const QString &room_id, + QWidget *parent = 0); + // m.image + TimelineItem(ImageItem *item, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent = 0); + TimelineItem(FileItem *item, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent = 0); + TimelineItem(AudioItem *item, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent = 0); + TimelineItem(VideoItem *item, + const QString &userid, + bool withSender, + const QString &room_id, + QWidget *parent = 0); + + TimelineItem(ImageItem *img, + const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent); + TimelineItem(StickerItem *img, + const mtx::events::Sticker &e, + bool with_sender, + const QString &room_id, + QWidget *parent); + TimelineItem(FileItem *file, + const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent); + TimelineItem(AudioItem *audio, + const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent); + TimelineItem(VideoItem *video, + const mtx::events::RoomEvent &e, + bool with_sender, + const QString &room_id, + QWidget *parent); + + void setUserAvatar(const QImage &pixmap); + DescInfo descriptionMessage() const { return descriptionMsg_; } + QString eventId() const { return event_id_; } + void setEventId(const QString &event_id) { event_id_ = event_id; } + void markReceived(bool isEncrypted); + void markSent(); + bool isReceived() { return isReceived_; }; + void setRoomId(QString room_id) { room_id_ = room_id; } + void sendReadReceipt() const; + + //! Add a user avatar for this event. + void addAvatar(); + void addKeyRequestAction(); + +signals: + void eventRedacted(const QString &event_id); + void redactionFailed(const QString &msg); + +protected: + void paintEvent(QPaintEvent *event) override; + void contextMenuEvent(QContextMenuEvent *event) override; + +private: + void init(); + //! Add a context menu option to save the image of the timeline item. + void addSaveImageAction(ImageItem *image); + //! Add the reply action in the context menu for widgets that support it. + void addReplyAction(); + + template + void setupLocalWidgetLayout(Widget *widget, const QString &userid, bool withSender); + + template + void setupWidgetLayout(Widget *widget, const Event &event, bool withSender); + + void generateBody(const QString &body); + void generateBody(const QString &user_id, const QString &displayname, const QString &body); + void generateTimestamp(const QDateTime &time); + + void setupAvatarLayout(const QString &userName); + void setupSimpleLayout(); + + void adjustMessageLayout(); + void adjustMessageLayoutForWidget(); + + //! Whether or not the event associated with the widget + //! has been acknowledged by the server. + bool isReceived_ = false; + + QString replaceEmoji(const QString &body); + QString event_id_; + QString room_id_; + + DescInfo descriptionMsg_; + + QMenu *contextMenu_; + QAction *showReadReceipts_; + QAction *markAsRead_; + QAction *redactMsg_; + QAction *replyMsg_; + + QHBoxLayout *topLayout_ = nullptr; + QHBoxLayout *messageLayout_ = nullptr; + QVBoxLayout *mainLayout_ = nullptr; + QHBoxLayout *widgetLayout_ = nullptr; + + Avatar *userAvatar_; + + QFont font_; + QFont usernameFont_; + + StatusIndicator *statusIndicator_; + + QLabel *timestamp_; + QLabel *userName_; + TextLabel *body_; +}; + +template +void +TimelineItem::setupLocalWidgetLayout(Widget *widget, const QString &userid, bool withSender) +{ + auto displayName = Cache::displayName(room_id_, userid); + auto timestamp = QDateTime::currentDateTime(); + + descriptionMsg_ = {"You", + userid, + QString(" %1").arg(utils::messageDescription()), + utils::descriptiveTime(timestamp), + timestamp}; + + generateTimestamp(timestamp); + + widgetLayout_ = new QHBoxLayout; + widgetLayout_->setContentsMargins(0, 2, 0, 2); + widgetLayout_->addWidget(widget); + widgetLayout_->addStretch(1); + + if (withSender) { + generateBody(userid, displayName, ""); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, userid, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + setupSimpleLayout(); + } + + adjustMessageLayoutForWidget(); +} + +template +void +TimelineItem::setupWidgetLayout(Widget *widget, const Event &event, bool withSender) +{ + init(); + + event_id_ = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + + auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts); + auto displayName = Cache::displayName(room_id_, sender); + + QSettings settings; + descriptionMsg_ = {sender == settings.value("auth/user_id") ? "You" : displayName, + sender, + QString(" %1").arg(utils::messageDescription()), + utils::descriptiveTime(timestamp), + timestamp}; + + generateTimestamp(timestamp); + + widgetLayout_ = new QHBoxLayout(); + widgetLayout_->setContentsMargins(0, 2, 0, 2); + widgetLayout_->addWidget(widget); + widgetLayout_->addStretch(1); + + if (withSender) { + generateBody(sender, displayName, ""); + setupAvatarLayout(displayName); + + AvatarProvider::resolve( + room_id_, sender, this, [this](const QImage &img) { setUserAvatar(img); }); + } else { + setupSimpleLayout(); + } + + adjustMessageLayoutForWidget(); +} diff --git a/src/timeline/TimelineView.cc b/src/timeline/TimelineView.cc deleted file mode 100644 index 207844e4..00000000 --- a/src/timeline/TimelineView.cc +++ /dev/null @@ -1,1459 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include - -#include "Cache.h" -#include "ChatPage.h" -#include "Config.h" -#include "FloatingButton.h" -#include "InfoMessage.hpp" -#include "Logging.hpp" -#include "Olm.hpp" -#include "UserSettingsPage.h" -#include "Utils.h" - -#include "timeline/TimelineView.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" - -using TimelineEvent = mtx::events::collections::TimelineEvents; - -//! Retrieve the timestamp of the event represented by the given widget. -QDateTime -getDate(QWidget *widget) -{ - auto item = qobject_cast(widget); - if (item) - return item->descriptionMessage().datetime; - - auto infoMsg = qobject_cast(widget); - if (infoMsg) - return infoMsg->datetime(); - - return QDateTime(); -} - -TimelineView::TimelineView(const mtx::responses::Timeline &timeline, - const QString &room_id, - QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - addEvents(timeline); -} - -TimelineView::TimelineView(const QString &room_id, QWidget *parent) - : QWidget(parent) - , room_id_{room_id} -{ - init(); - getMessages(); -} - -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() -{ - if (!isScrollbarActivated() && !isTimelineFinished) { - if (!isVisible()) - return; - - isPaginationInProgress_ = true; - getMessages(); - paginationTimer_->start(2000); - - 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; - - toggleScrollDownButton(); - - // 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. - if (isPaginationInProgress_) - return; - - isPaginationInProgress_ = true; - - getMessages(); - } -} - -void -TimelineView::addBackwardsEvents(const mtx::responses::Messages &msgs) -{ - // We've reached the start of the timline and there're no more messages. - if ((msgs.end == msgs.start) && msgs.chunk.size() == 0) { - isTimelineFinished = true; - return; - } - - isTimelineFinished = false; - - // Queue incoming messages to be rendered later. - topMessages_.insert(topMessages_.end(), - std::make_move_iterator(msgs.chunk.begin()), - std::make_move_iterator(msgs.chunk.end())); - - // The RoomList message preview will be updated only if this - // is the first batch of messages received through /messages - // i.e there are no other messages currently present. - if (!topMessages_.empty() && scroll_layout_->count() == 1) - notifyForLastEvent(findFirstViewableEvent(topMessages_)); - - if (isVisible()) { - renderTopEvents(topMessages_); - - // Free up space for new messages. - topMessages_.clear(); - - // Send a read receipt for the last event. - if (isActiveWindow()) - readLastEvent(); - } - - prev_batch_token_ = QString::fromStdString(msgs.end); - isPaginationInProgress_ = false; -} - -QWidget * -TimelineView::parseMessageEvent(const mtx::events::collections::TimelineEvents &event, - TimelineDirection direction) -{ - using namespace mtx::events; - - using AudioEvent = RoomEvent; - using EmoteEvent = RoomEvent; - using FileEvent = RoomEvent; - using ImageEvent = RoomEvent; - using NoticeEvent = RoomEvent; - using TextEvent = RoomEvent; - using VideoEvent = RoomEvent; - - if (mpark::holds_alternative>(event)) { - auto redaction_event = mpark::get>(event); - const auto event_id = QString::fromStdString(redaction_event.redacts); - - QTimer::singleShot(0, this, [event_id, this]() { - if (eventIds_.contains(event_id)) - removeEvent(event_id); - }); - - return nullptr; - } else if (mpark::holds_alternative>(event)) { - auto msg = mpark::get>(event); - auto event_id = QString::fromStdString(msg.event_id); - - if (eventIds_.contains(event_id)) - return nullptr; - - auto item = new InfoMessage(tr("Encryption is enabled"), this); - item->saveDatetime(QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts)); - eventIds_[event_id] = item; - - return item; - } else if (mpark::holds_alternative>(event)) { - auto audio = mpark::get>(event); - return processMessageEvent(audio, direction); - } else if (mpark::holds_alternative>(event)) { - auto emote = mpark::get>(event); - return processMessageEvent(emote, direction); - } else if (mpark::holds_alternative>(event)) { - auto file = mpark::get>(event); - return processMessageEvent(file, direction); - } else if (mpark::holds_alternative>(event)) { - auto image = mpark::get>(event); - return processMessageEvent(image, direction); - } else if (mpark::holds_alternative>(event)) { - auto notice = mpark::get>(event); - return processMessageEvent(notice, direction); - } else if (mpark::holds_alternative>(event)) { - auto text = mpark::get>(event); - return processMessageEvent(text, direction); - } else if (mpark::holds_alternative>(event)) { - auto video = mpark::get>(event); - return processMessageEvent(video, direction); - } else if (mpark::holds_alternative(event)) { - return processMessageEvent(mpark::get(event), - direction); - } else if (mpark::holds_alternative>(event)) { - auto res = parseEncryptedEvent(mpark::get>(event)); - auto widget = parseMessageEvent(res.event, direction); - - if (widget == nullptr) - return nullptr; - - auto item = qobject_cast(widget); - - if (item && res.isDecrypted) - item->markReceived(true); - else if (item && !res.isDecrypted) - item->addKeyRequestAction(); - - return widget; - } - - return nullptr; -} - -DecryptionResult -TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent &e) -{ - MegolmSessionIndex index; - index.room_id = room_id_.toStdString(); - index.session_id = e.content.session_id; - index.sender_key = e.content.sender_key; - - mtx::events::RoomEvent dummy; - dummy.origin_server_ts = e.origin_server_ts; - dummy.event_id = e.event_id; - dummy.sender = e.sender; - dummy.content.body = "-- Encrypted Event (No keys found for decryption) --"; - - try { - if (!cache::client()->inboundMegolmSessionExists(index)) { - nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", - index.room_id, - index.session_id, - e.sender); - // TODO: request megolm session_id & session_key from the sender. - return {dummy, false}; - } - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to check megolm session's existence: {}", e.what()); - dummy.content.body = "-- Decryption Error (failed to communicate with DB) --"; - return {dummy, false}; - } - - std::string msg_str; - try { - auto session = cache::client()->getInboundMegolmSession(index); - auto res = olm::client()->decrypt_group_message(session, e.content.ciphertext); - msg_str = std::string((char *)res.data.data(), res.data.size()); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = - "-- Decryption Error (failed to retrieve megolm keys from db) --"; - return {dummy, false}; - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = "-- Decryption Error (" + std::string(e.what()) + ") --"; - return {dummy, false}; - } - - // Add missing fields for the event. - json body = json::parse(msg_str); - body["event_id"] = e.event_id; - body["sender"] = e.sender; - body["origin_server_ts"] = e.origin_server_ts; - body["unsigned"] = e.unsigned_data; - - nhlog::crypto()->info("decrypted event: {}", e.event_id); - nhlog::crypto()->debug("decrypted data: \n {}", body.dump(2)); - - json event_array = json::array(); - event_array.push_back(body); - - std::vector events; - mtx::responses::utils::parse_timeline_events(event_array, events); - - if (events.size() == 1) - return {events.at(0), true}; - - dummy.content.body = "-- Encrypted Event (Unknown event type) --"; - return {dummy, false}; -} - -void -TimelineView::renderBottomEvents(const std::vector &events) -{ - int counter = 0; - - for (const auto &event : events) { - QWidget *item = parseMessageEvent(event, TimelineDirection::Bottom); - - if (item != nullptr) { - addTimelineItem(item, TimelineDirection::Bottom); - counter++; - - // Prevent blocking of the event-loop - // by calling processEvents every 10 items we render. - if (counter % 4 == 0) - QApplication::processEvents(); - } - } - - lastMessageDirection_ = TimelineDirection::Bottom; - - QApplication::processEvents(); -} - -void -TimelineView::renderTopEvents(const std::vector &events) -{ - std::vector items; - - // Reset the sender of the first message in the timeline - // cause we're about to insert a new one. - firstSender_.clear(); - firstMsgTimestamp_ = QDateTime(); - - // Parse in reverse order to determine where we should not show sender's name. - for (auto it = events.rbegin(); it != events.rend(); ++it) { - auto item = parseMessageEvent(*it, 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(); - - // 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.empty()) { - for (const auto &w : items) { - auto timelineItem = qobject_cast(w); - if (timelineItem) { - saveLastMessageInfo(timelineItem->descriptionMessage().userid, - timelineItem->descriptionMessage().datetime); - break; - } - } - } -} - -void -TimelineView::addEvents(const mtx::responses::Timeline &timeline) -{ - if (isInitialSync) { - prev_batch_token_ = QString::fromStdString(timeline.prev_batch); - isInitialSync = false; - } - - bottomMessages_.insert(bottomMessages_.end(), - std::make_move_iterator(timeline.events.begin()), - std::make_move_iterator(timeline.events.end())); - - if (!bottomMessages_.empty()) - notifyForLastEvent(findLastViewableEvent(bottomMessages_)); - - // If the current timeline is open and there are messages to be rendered. - if (isVisible() && !bottomMessages_.empty()) { - renderBottomEvents(bottomMessages_); - - // Free up space for new messages. - bottomMessages_.clear(); - - // Send a read receipt for the last event. - if (isActiveWindow()) - readLastEvent(); - } -} - -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, [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(4, 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(this, &TimelineView::messagesRetrieved, this, &TimelineView::addBackwardsEvents); - - connect(this, &TimelineView::messageFailed, this, &TimelineView::handleFailedMessage); - connect(this, &TimelineView::messageSent, this, &TimelineView::updatePendingMessage); - - 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::getMessages() -{ - mtx::http::MessagesOpts opts; - opts.room_id = room_id_.toStdString(); - opts.from = prev_batch_token_.toStdString(); - - http::client()->messages( - opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error("failed to call /messages ({}): {} - {}", - opts.room_id, - mtx::errors::to_string(err->matrix_error.errcode), - err->matrix_error.error); - return; - } - - emit messagesRetrieved(std::move(res)); - }); -} - -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, - uint64_t origin_server_ts, - TimelineDirection direction) -{ - if (direction == TimelineDirection::Bottom) { - return (lastSender_ != user_id) || - isDateDifference(lastMsgTimestamp_, - QDateTime::fromMSecsSinceEpoch(origin_server_ts)); - } else { - return (firstSender_ != user_id) || - isDateDifference(firstMsgTimestamp_, - QDateTime::fromMSecsSinceEpoch(origin_server_ts)); - } -} - -void -TimelineView::addTimelineItem(QWidget *item, TimelineDirection direction) -{ - const auto newDate = getDate(item); - - if (direction == TimelineDirection::Bottom) { - const auto lastItemPosition = scroll_layout_->count() - 1; - const auto lastItem = scroll_layout_->itemAt(lastItemPosition)->widget(); - - if (lastItem) { - const auto oldDate = getDate(lastItem); - - if (oldDate.daysTo(newDate) != 0) { - auto separator = new DateSeparator(newDate, this); - - if (separator) - scroll_layout_->addWidget(separator); - } - } - - pushTimelineItem(item); - } else { - // The first item (position 0) is a stretch widget that pushes - // the widgets to the bottom of the page. - if (scroll_layout_->count() > 1) { - const auto firstItem = scroll_layout_->itemAt(1)->widget(); - - if (firstItem) { - const auto oldDate = getDate(firstItem); - - if (newDate.daysTo(oldDate) != 0) { - auto separator = new DateSeparator(oldDate); - - if (separator) - scroll_layout_->insertWidget(1, separator); - } - } - } - - scroll_layout_->insertWidget(1, item); - } -} - -void -TimelineView::updatePendingMessage(const std::string &txn_id, const QString &event_id) -{ - nhlog::ui()->info("[{}] message was received by the server", txn_id); - if (!pending_msgs_.isEmpty() && - pending_msgs_.head().txn_id == txn_id) { // We haven't received it yet - auto msg = pending_msgs_.dequeue(); - msg.event_id = event_id; - - if (msg.widget) { - msg.widget->setEventId(event_id); - eventIds_[event_id] = msg.widget; - - // If the response comes after we have received the event from sync - // we've already marked the widget as received. - if (!msg.widget->isReceived()) { - msg.widget->markReceived(msg.is_encrypted); - pending_sent_msgs_.append(msg); - } - } else { - nhlog::ui()->warn("[{}] received message response for invalid widget", - txn_id); - } - } - - sendNextPendingMessage(); -} - -void -TimelineView::addUserMessage(mtx::events::MessageType ty, const QString &body) -{ - auto with_sender = (lastSender_ != local_user_) || isDateDifference(lastMsgTimestamp_); - - TimelineItem *view_item = - new TimelineItem(ty, local_user_, body, with_sender, room_id_, scroll_widget_); - - PendingMessage message; - message.ty = ty; - message.txn_id = http::client()->generate_txn_id(); - message.body = body; - message.widget = view_item; - - try { - message.is_encrypted = cache::client()->isRoomEncrypted(room_id_.toStdString()); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to check encryption status of room {}", e.what()); - view_item->deleteLater(); - - // TODO: Send a notification to the user. - - return; - } - - addTimelineItem(view_item); - - lastMessageDirection_ = TimelineDirection::Bottom; - - saveLastMessageInfo(local_user_, QDateTime::currentDateTime()); - handleNewUserMessage(message); -} - -void -TimelineView::handleNewUserMessage(PendingMessage msg) -{ - pending_msgs_.enqueue(msg); - if (pending_msgs_.size() == 1 && pending_sent_msgs_.isEmpty()) - sendNextPendingMessage(); -} - -void -TimelineView::sendNextPendingMessage() -{ - if (pending_msgs_.size() == 0) - return; - - using namespace mtx::events; - - PendingMessage &m = pending_msgs_.head(); - - nhlog::ui()->info("[{}] sending next queued message", m.txn_id); - - if (m.widget) - m.widget->markSent(); - - if (m.is_encrypted) { - nhlog::ui()->info("[{}] sending encrypted event", m.txn_id); - prepareEncryptedMessage(std::move(m)); - return; - } - - switch (m.ty) { - case mtx::events::MessageType::Audio: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Image: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Video: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::File: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Text: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - break; - } - case mtx::events::MessageType::Emote: { - http::client()->send_room_message( - room_id_.toStdString(), - m.txn_id, - toRoomMessage(m), - std::bind(&TimelineView::sendRoomMessageHandler, - this, - m.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - break; - } - default: - nhlog::ui()->warn("cannot send unknown message type: {}", m.body.toStdString()); - break; - } -} - -void -TimelineView::notifyForLastEvent() -{ - auto lastItem = scroll_layout_->itemAt(scroll_layout_->count() - 1); - auto *lastTimelineItem = qobject_cast(lastItem->widget()); - - if (lastTimelineItem) - emit updateLastTimelineMessage(room_id_, lastTimelineItem->descriptionMessage()); - else - nhlog::ui()->warn("cast to TimelineView failed: {}", room_id_.toStdString()); -} - -void -TimelineView::notifyForLastEvent(const TimelineEvent &event) -{ - auto descInfo = utils::getMessageDescription(event, local_user_, room_id_); - - if (!descInfo.timestamp.isEmpty()) - emit updateLastTimelineMessage(room_id_, descInfo); -} - -bool -TimelineView::isPendingMessage(const std::string &txn_id, - const QString &sender, - const QString &local_userid) -{ - if (sender != local_userid) - return false; - - auto match_txnid = [txn_id](const auto &msg) -> bool { return msg.txn_id == txn_id; }; - - return std::any_of(pending_msgs_.cbegin(), pending_msgs_.cend(), match_txnid) || - std::any_of(pending_sent_msgs_.cbegin(), pending_sent_msgs_.cend(), match_txnid); -} - -void -TimelineView::removePendingMessage(const std::string &txn_id) -{ - if (txn_id.empty()) - return; - - for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) { - if (it->txn_id == txn_id) { - int index = std::distance(pending_sent_msgs_.begin(), it); - pending_sent_msgs_.removeAt(index); - - if (pending_sent_msgs_.isEmpty()) - sendNextPendingMessage(); - - nhlog::ui()->info("[{}] removed message with sync", txn_id); - } - } - for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) { - if (it->txn_id == txn_id) { - if (it->widget) - it->widget->markReceived(it->is_encrypted); - - nhlog::ui()->info("[{}] received sync before message response", txn_id); - return; - } - } -} - -void -TimelineView::handleFailedMessage(const std::string &txn_id) -{ - Q_UNUSED(txn_id); - // Note: We do this even if the message has already been echoed. - QTimer::singleShot(2000, 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 -{ - if (!ChatPage::instance()->userSettings()->isReadReceiptsEnabled()) - return; - - const auto eventId = getLastEventId(); - - if (!eventId.isEmpty()) - http::client()->read_event(room_id_.toStdString(), - eventId.toStdString(), - [this, eventId](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to read event ({}, {})", - room_id_.toStdString(), - eventId.toStdString()); - } - }); -} - -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(lastItem->widget()); - - if (lastTimelineItem && !lastTimelineItem->eventId().isEmpty()) - return lastTimelineItem->eventId(); - } - - return QString(""); -} - -void -TimelineView::showEvent(QShowEvent *event) -{ - if (!topMessages_.empty()) { - renderTopEvents(topMessages_); - topMessages_.clear(); - } - - if (!bottomMessages_.empty()) { - renderBottomEvents(bottomMessages_); - bottomMessages_.clear(); - scrollDown(); - } - - toggleScrollDownButton(); - - readLastEvent(); - - QWidget::showEvent(event); -} - -bool -TimelineView::event(QEvent *event) -{ - if (event->type() == QEvent::WindowActivate) - readLastEvent(); - - return QWidget::event(event); -} - -void -TimelineView::toggleScrollDownButton() -{ - 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(); - } -} - -void -TimelineView::removeEvent(const QString &event_id) -{ - if (!eventIds_.contains(event_id)) { - nhlog::ui()->warn("cannot remove widget with unknown event_id: {}", - event_id.toStdString()); - return; - } - - auto removedItem = eventIds_[event_id]; - - // Find the next and the previous widgets in the timeline - auto prevWidget = relativeWidget(removedItem, -1); - auto nextWidget = relativeWidget(removedItem, 1); - - // See if they are timeline items - auto prevItem = qobject_cast(prevWidget); - auto nextItem = qobject_cast(nextWidget); - - // ... or a date separator - auto prevLabel = qobject_cast(prevWidget); - - // If it's a TimelineItem add an avatar. - if (prevItem) { - prevItem->addAvatar(); - } - - if (nextItem) { - nextItem->addAvatar(); - } else if (prevLabel) { - // If there's no chat message after this, and we have a label before us, delete the - // label. - prevLabel->deleteLater(); - } - - // If we deleted the last item in the timeline... - if (!nextItem && prevItem) - saveLastMessageInfo(prevItem->descriptionMessage().userid, - prevItem->descriptionMessage().datetime); - - // If we deleted the first item in the timeline... - if (!prevItem && nextItem) - saveFirstMessageInfo(nextItem->descriptionMessage().userid, - nextItem->descriptionMessage().datetime); - - // If we deleted the only item in the timeline... - if (!prevItem && !nextItem) { - firstSender_.clear(); - firstMsgTimestamp_ = QDateTime(); - lastSender_.clear(); - lastMsgTimestamp_ = QDateTime(); - } - - // Finally remove the event. - removedItem->deleteLater(); - eventIds_.remove(event_id); - - // Update the room list with a view of the last message after - // all events have been processed. - QTimer::singleShot(0, this, [this]() { notifyForLastEvent(); }); -} - -QWidget * -TimelineView::relativeWidget(QWidget *item, int dt) const -{ - int pos = scroll_layout_->indexOf(item); - - if (pos == -1) - return nullptr; - - pos = pos + dt; - - bool isOutOfBounds = (pos <= 0 || pos > scroll_layout_->count() - 1); - - return isOutOfBounds ? nullptr : scroll_layout_->itemAt(pos)->widget(); -} - -TimelineEvent -TimelineView::findFirstViewableEvent(const std::vector &events) -{ - auto it = std::find_if(events.begin(), events.end(), [](const auto &event) { - return mtx::events::EventType::RoomMessage == utils::event_type(event); - }); - - return (it == std::end(events)) ? events.front() : *it; -} - -TimelineEvent -TimelineView::findLastViewableEvent(const std::vector &events) -{ - auto it = std::find_if(events.rbegin(), events.rend(), [](const auto &event) { - return (mtx::events::EventType::RoomMessage == utils::event_type(event)) || - (mtx::events::EventType::RoomEncrypted == utils::event_type(event)); - }); - - return (it == std::rend(events)) ? events.back() : *it; -} - -void -TimelineView::saveMessageInfo(const QString &sender, - uint64_t origin_server_ts, - TimelineDirection direction) -{ - updateLastSender(sender, direction); - - if (direction == TimelineDirection::Bottom) - lastMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); - else - firstMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); -} - -bool -TimelineView::isDateDifference(const QDateTime &first, const QDateTime &second) const -{ - // Check if the dates are in a different day. - if (std::abs(first.daysTo(second)) != 0) - return true; - - const uint64_t diffInSeconds = std::abs(first.msecsTo(second)) / 1000; - constexpr uint64_t fifteenMins = 15 * 60; - - return diffInSeconds > fifteenMins; -} - -void -TimelineView::sendRoomMessageHandler(const std::string &txn_id, - const mtx::responses::EventId &res, - mtx::http::RequestErr err) -{ - if (err) { - const int status_code = static_cast(err->status_code); - nhlog::net()->warn("[{}] failed to send message: {} {}", - txn_id, - err->matrix_error.error, - status_code); - emit messageFailed(txn_id); - return; - } - - emit messageSent(txn_id, QString::fromStdString(res.event_id.to_string())); -} - -template<> -mtx::events::msg::Audio -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Audio audio; - audio.info.mimetype = m.mime.toStdString(); - audio.info.size = m.media_size; - audio.body = m.filename.toStdString(); - audio.url = m.body.toStdString(); - return audio; -} - -template<> -mtx::events::msg::Image -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Image image; - image.info.mimetype = m.mime.toStdString(); - image.info.size = m.media_size; - image.body = m.filename.toStdString(); - image.url = m.body.toStdString(); - image.info.h = m.dimensions.height(); - image.info.w = m.dimensions.width(); - return image; -} - -template<> -mtx::events::msg::Video -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Video video; - video.info.mimetype = m.mime.toStdString(); - video.info.size = m.media_size; - video.body = m.filename.toStdString(); - video.url = m.body.toStdString(); - return video; -} - -template<> -mtx::events::msg::Emote -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Emote emote; - emote.body = m.body.toStdString(); - return emote; -} - -template<> -mtx::events::msg::File -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::File file; - file.info.mimetype = m.mime.toStdString(); - file.info.size = m.media_size; - file.body = m.filename.toStdString(); - file.url = m.body.toStdString(); - return file; -} - -template<> -mtx::events::msg::Text -toRoomMessage(const PendingMessage &m) -{ - mtx::events::msg::Text text; - text.body = m.body.toStdString(); - return text; -} - -void -TimelineView::prepareEncryptedMessage(const PendingMessage &msg) -{ - const auto room_id = room_id_.toStdString(); - - using namespace mtx::events; - using namespace mtx::identifiers; - - json content; - - // Serialize the message to the plaintext that will be encrypted. - switch (msg.ty) { - case MessageType::Audio: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Emote: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::File: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Image: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Text: { - content = json(toRoomMessage(msg)); - break; - } - case MessageType::Video: { - content = json(toRoomMessage(msg)); - break; - } - default: - break; - } - - json doc{{"type", "m.room.message"}, {"content", content}, {"room_id", room_id}}; - - try { - // Check if we have already an outbound megolm session then we can use. - if (cache::client()->outboundMegolmSessionExists(room_id)) { - auto data = olm::encrypt_group_message( - room_id, http::client()->device_id(), doc.dump()); - - http::client()->send_room_message( - room_id, - msg.txn_id, - data, - std::bind(&TimelineView::sendRoomMessageHandler, - this, - msg.txn_id, - std::placeholders::_1, - std::placeholders::_2)); - return; - } - - nhlog::ui()->info("creating new outbound megolm session"); - - // Create a new outbound megolm session. - auto outbound_session = olm::client()->init_outbound_group_session(); - const auto session_id = mtx::crypto::session_id(outbound_session.get()); - const auto session_key = mtx::crypto::session_key(outbound_session.get()); - - // TODO: needs to be moved in the lib. - auto megolm_payload = json{{"algorithm", "m.megolm.v1.aes-sha2"}, - {"room_id", room_id}, - {"session_id", session_id}, - {"session_key", session_key}}; - - // Saving the new megolm session. - // TODO: Maybe it's too early to save. - OutboundGroupSessionData session_data; - session_data.session_id = session_id; - session_data.session_key = session_key; - session_data.message_index = 0; // TODO Update me - cache::client()->saveOutboundMegolmSession( - room_id, session_data, std::move(outbound_session)); - - const auto members = cache::client()->roomMembers(room_id); - nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id); - - auto keeper = std::make_shared( - [megolm_payload, room_id, doc, txn_id = msg.txn_id, this]() { - try { - auto data = olm::encrypt_group_message( - room_id, http::client()->device_id(), doc.dump()); - - http::client() - ->send_room_message( - room_id, - txn_id, - data, - std::bind(&TimelineView::sendRoomMessageHandler, - this, - txn_id, - std::placeholders::_1, - std::placeholders::_2)); - - } catch (const lmdb::error &e) { - nhlog::db()->critical( - "failed to save megolm outbound session: {}", e.what()); - } - }); - - mtx::requests::QueryKeys req; - for (const auto &member : members) - req.device_keys[member] = {}; - - http::client()->query_keys( - req, - [keeper = std::move(keeper), megolm_payload, this]( - const mtx::responses::QueryKeys &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to query device keys: {} {}", - err->matrix_error.error, - static_cast(err->status_code)); - // TODO: Mark the event as failed. Communicate with the UI. - return; - } - - for (const auto &user : res.device_keys) { - // Mapping from a device_id with valid identity keys to the - // generated room_key event used for sharing the megolm session. - std::map room_key_msgs; - std::map deviceKeys; - - room_key_msgs.clear(); - deviceKeys.clear(); - - for (const auto &dev : user.second) { - const auto user_id = UserId(dev.second.user_id); - const auto device_id = DeviceId(dev.second.device_id); - - const auto device_keys = dev.second.keys; - const auto curveKey = "curve25519:" + device_id.get(); - const auto edKey = "ed25519:" + device_id.get(); - - if ((device_keys.find(curveKey) == device_keys.end()) || - (device_keys.find(edKey) == device_keys.end())) { - nhlog::net()->info( - "ignoring malformed keys for device {}", - device_id.get()); - continue; - } - - DevicePublicKeys pks; - pks.ed25519 = device_keys.at(edKey); - pks.curve25519 = device_keys.at(curveKey); - - try { - if (!mtx::crypto::verify_identity_signature( - json(dev.second), device_id, user_id)) { - nhlog::crypto()->warn( - "failed to verify identity keys: {}", - json(dev.second).dump(2)); - continue; - } - } catch (const json::exception &e) { - nhlog::crypto()->warn( - "failed to parse device key json: {}", - e.what()); - continue; - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->warn( - "failed to verify device key json: {}", - e.what()); - continue; - } - - auto room_key = olm::client() - ->create_room_key_event( - user_id, pks.ed25519, megolm_payload) - .dump(); - - room_key_msgs.emplace(device_id, room_key); - deviceKeys.emplace(device_id, pks); - } - - std::vector valid_devices; - valid_devices.reserve(room_key_msgs.size()); - for (auto const &d : room_key_msgs) { - valid_devices.push_back(d.first); - - nhlog::net()->info("{}", d.first); - nhlog::net()->info(" curve25519 {}", - deviceKeys.at(d.first).curve25519); - nhlog::net()->info(" ed25519 {}", - deviceKeys.at(d.first).ed25519); - } - - nhlog::net()->info( - "sending claim request for user {} with {} devices", - user.first, - valid_devices.size()); - - http::client()->claim_keys( - user.first, - valid_devices, - std::bind(&TimelineView::handleClaimedKeys, - this, - keeper, - room_key_msgs, - deviceKeys, - user.first, - std::placeholders::_1, - std::placeholders::_2)); - - // TODO: Wait before sending the next batch of requests. - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - }); - - // TODO: Let the user know about the errors. - } catch (const lmdb::error &e) { - nhlog::db()->critical( - "failed to open outbound megolm session ({}): {}", room_id, e.what()); - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical( - "failed to open outbound megolm session ({}): {}", room_id, e.what()); - } -} - -void -TimelineView::handleClaimedKeys(std::shared_ptr keeper, - const std::map &room_keys, - const std::map &pks, - const std::string &user_id, - const mtx::responses::ClaimKeys &res, - mtx::http::RequestErr err) -{ - if (err) { - nhlog::net()->warn("claim keys error: {} {} {}", - err->matrix_error.error, - err->parse_error, - static_cast(err->status_code)); - return; - } - - nhlog::net()->info("claimed keys for {}", user_id); - - if (res.one_time_keys.size() == 0) { - nhlog::net()->info("no one-time keys found for user_id: {}", user_id); - return; - } - - if (res.one_time_keys.find(user_id) == res.one_time_keys.end()) { - nhlog::net()->info("no one-time keys found for user_id: {}", user_id); - return; - } - - auto retrieved_devices = res.one_time_keys.at(user_id); - - // Payload with all the to_device message to be sent. - json body; - body["messages"][user_id] = json::object(); - - for (const auto &rd : retrieved_devices) { - const auto device_id = rd.first; - nhlog::net()->info("{} : \n {}", device_id, rd.second.dump(2)); - - // TODO: Verify signatures - auto otk = rd.second.begin()->at("key"); - - if (pks.find(device_id) == pks.end()) { - nhlog::net()->critical("couldn't find public key for device: {}", - device_id); - continue; - } - - auto id_key = pks.at(device_id).curve25519; - auto s = olm::client()->create_outbound_session(id_key, otk); - - if (room_keys.find(device_id) == room_keys.end()) { - nhlog::net()->critical("couldn't find m.room_key for device: {}", - device_id); - continue; - } - - auto device_msg = olm::client()->create_olm_encrypted_content( - s.get(), room_keys.at(device_id), pks.at(device_id).curve25519); - - try { - cache::client()->saveOlmSession(id_key, std::move(s)); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to save outbound olm session: {}", e.what()); - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to pickle outbound olm session: {}", - e.what()); - } - - body["messages"][user_id][device_id] = device_msg; - } - - nhlog::net()->info("send_to_device: {}", user_id); - - http::client()->send_to_device( - "m.room.encrypted", body, [keeper](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to send " - "send_to_device " - "message: {}", - err->matrix_error.error); - } - - (void)keeper; - }); -} diff --git a/src/timeline/TimelineView.cpp b/src/timeline/TimelineView.cpp new file mode 100644 index 00000000..a8c04807 --- /dev/null +++ b/src/timeline/TimelineView.cpp @@ -0,0 +1,1459 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include + +#include "Cache.h" +#include "ChatPage.h" +#include "Config.h" +#include "Logging.h" +#include "Olm.h" +#include "UserSettingsPage.h" +#include "Utils.h" +#include "ui/FloatingButton.h" +#include "ui/InfoMessage.h" + +#include "timeline/TimelineView.h" +#include "timeline/widgets/AudioItem.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" +#include "timeline/widgets/VideoItem.h" + +using TimelineEvent = mtx::events::collections::TimelineEvents; + +//! Retrieve the timestamp of the event represented by the given widget. +QDateTime +getDate(QWidget *widget) +{ + auto item = qobject_cast(widget); + if (item) + return item->descriptionMessage().datetime; + + auto infoMsg = qobject_cast(widget); + if (infoMsg) + return infoMsg->datetime(); + + return QDateTime(); +} + +TimelineView::TimelineView(const mtx::responses::Timeline &timeline, + const QString &room_id, + QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + addEvents(timeline); +} + +TimelineView::TimelineView(const QString &room_id, QWidget *parent) + : QWidget(parent) + , room_id_{room_id} +{ + init(); + getMessages(); +} + +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() +{ + if (!isScrollbarActivated() && !isTimelineFinished) { + if (!isVisible()) + return; + + isPaginationInProgress_ = true; + getMessages(); + paginationTimer_->start(2000); + + 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; + + toggleScrollDownButton(); + + // 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. + if (isPaginationInProgress_) + return; + + isPaginationInProgress_ = true; + + getMessages(); + } +} + +void +TimelineView::addBackwardsEvents(const mtx::responses::Messages &msgs) +{ + // We've reached the start of the timline and there're no more messages. + if ((msgs.end == msgs.start) && msgs.chunk.size() == 0) { + isTimelineFinished = true; + return; + } + + isTimelineFinished = false; + + // Queue incoming messages to be rendered later. + topMessages_.insert(topMessages_.end(), + std::make_move_iterator(msgs.chunk.begin()), + std::make_move_iterator(msgs.chunk.end())); + + // The RoomList message preview will be updated only if this + // is the first batch of messages received through /messages + // i.e there are no other messages currently present. + if (!topMessages_.empty() && scroll_layout_->count() == 1) + notifyForLastEvent(findFirstViewableEvent(topMessages_)); + + if (isVisible()) { + renderTopEvents(topMessages_); + + // Free up space for new messages. + topMessages_.clear(); + + // Send a read receipt for the last event. + if (isActiveWindow()) + readLastEvent(); + } + + prev_batch_token_ = QString::fromStdString(msgs.end); + isPaginationInProgress_ = false; +} + +QWidget * +TimelineView::parseMessageEvent(const mtx::events::collections::TimelineEvents &event, + TimelineDirection direction) +{ + using namespace mtx::events; + + using AudioEvent = RoomEvent; + using EmoteEvent = RoomEvent; + using FileEvent = RoomEvent; + using ImageEvent = RoomEvent; + using NoticeEvent = RoomEvent; + using TextEvent = RoomEvent; + using VideoEvent = RoomEvent; + + if (mpark::holds_alternative>(event)) { + auto redaction_event = mpark::get>(event); + const auto event_id = QString::fromStdString(redaction_event.redacts); + + QTimer::singleShot(0, this, [event_id, this]() { + if (eventIds_.contains(event_id)) + removeEvent(event_id); + }); + + return nullptr; + } else if (mpark::holds_alternative>(event)) { + auto msg = mpark::get>(event); + auto event_id = QString::fromStdString(msg.event_id); + + if (eventIds_.contains(event_id)) + return nullptr; + + auto item = new InfoMessage(tr("Encryption is enabled"), this); + item->saveDatetime(QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts)); + eventIds_[event_id] = item; + + return item; + } else if (mpark::holds_alternative>(event)) { + auto audio = mpark::get>(event); + return processMessageEvent(audio, direction); + } else if (mpark::holds_alternative>(event)) { + auto emote = mpark::get>(event); + return processMessageEvent(emote, direction); + } else if (mpark::holds_alternative>(event)) { + auto file = mpark::get>(event); + return processMessageEvent(file, direction); + } else if (mpark::holds_alternative>(event)) { + auto image = mpark::get>(event); + return processMessageEvent(image, direction); + } else if (mpark::holds_alternative>(event)) { + auto notice = mpark::get>(event); + return processMessageEvent(notice, direction); + } else if (mpark::holds_alternative>(event)) { + auto text = mpark::get>(event); + return processMessageEvent(text, direction); + } else if (mpark::holds_alternative>(event)) { + auto video = mpark::get>(event); + return processMessageEvent(video, direction); + } else if (mpark::holds_alternative(event)) { + return processMessageEvent(mpark::get(event), + direction); + } else if (mpark::holds_alternative>(event)) { + auto res = parseEncryptedEvent(mpark::get>(event)); + auto widget = parseMessageEvent(res.event, direction); + + if (widget == nullptr) + return nullptr; + + auto item = qobject_cast(widget); + + if (item && res.isDecrypted) + item->markReceived(true); + else if (item && !res.isDecrypted) + item->addKeyRequestAction(); + + return widget; + } + + return nullptr; +} + +DecryptionResult +TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent &e) +{ + MegolmSessionIndex index; + index.room_id = room_id_.toStdString(); + index.session_id = e.content.session_id; + index.sender_key = e.content.sender_key; + + mtx::events::RoomEvent dummy; + dummy.origin_server_ts = e.origin_server_ts; + dummy.event_id = e.event_id; + dummy.sender = e.sender; + dummy.content.body = "-- Encrypted Event (No keys found for decryption) --"; + + try { + if (!cache::client()->inboundMegolmSessionExists(index)) { + nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", + index.room_id, + index.session_id, + e.sender); + // TODO: request megolm session_id & session_key from the sender. + return {dummy, false}; + } + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to check megolm session's existence: {}", e.what()); + dummy.content.body = "-- Decryption Error (failed to communicate with DB) --"; + return {dummy, false}; + } + + std::string msg_str; + try { + auto session = cache::client()->getInboundMegolmSession(index); + auto res = olm::client()->decrypt_group_message(session, e.content.ciphertext); + msg_str = std::string((char *)res.data.data(), res.data.size()); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})", + index.room_id, + index.session_id, + index.sender_key, + e.what()); + dummy.content.body = + "-- Decryption Error (failed to retrieve megolm keys from db) --"; + return {dummy, false}; + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}", + index.room_id, + index.session_id, + index.sender_key, + e.what()); + dummy.content.body = "-- Decryption Error (" + std::string(e.what()) + ") --"; + return {dummy, false}; + } + + // Add missing fields for the event. + json body = json::parse(msg_str); + body["event_id"] = e.event_id; + body["sender"] = e.sender; + body["origin_server_ts"] = e.origin_server_ts; + body["unsigned"] = e.unsigned_data; + + nhlog::crypto()->info("decrypted event: {}", e.event_id); + nhlog::crypto()->debug("decrypted data: \n {}", body.dump(2)); + + json event_array = json::array(); + event_array.push_back(body); + + std::vector events; + mtx::responses::utils::parse_timeline_events(event_array, events); + + if (events.size() == 1) + return {events.at(0), true}; + + dummy.content.body = "-- Encrypted Event (Unknown event type) --"; + return {dummy, false}; +} + +void +TimelineView::renderBottomEvents(const std::vector &events) +{ + int counter = 0; + + for (const auto &event : events) { + QWidget *item = parseMessageEvent(event, TimelineDirection::Bottom); + + if (item != nullptr) { + addTimelineItem(item, TimelineDirection::Bottom); + counter++; + + // Prevent blocking of the event-loop + // by calling processEvents every 10 items we render. + if (counter % 4 == 0) + QApplication::processEvents(); + } + } + + lastMessageDirection_ = TimelineDirection::Bottom; + + QApplication::processEvents(); +} + +void +TimelineView::renderTopEvents(const std::vector &events) +{ + std::vector items; + + // Reset the sender of the first message in the timeline + // cause we're about to insert a new one. + firstSender_.clear(); + firstMsgTimestamp_ = QDateTime(); + + // Parse in reverse order to determine where we should not show sender's name. + for (auto it = events.rbegin(); it != events.rend(); ++it) { + auto item = parseMessageEvent(*it, 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(); + + // 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.empty()) { + for (const auto &w : items) { + auto timelineItem = qobject_cast(w); + if (timelineItem) { + saveLastMessageInfo(timelineItem->descriptionMessage().userid, + timelineItem->descriptionMessage().datetime); + break; + } + } + } +} + +void +TimelineView::addEvents(const mtx::responses::Timeline &timeline) +{ + if (isInitialSync) { + prev_batch_token_ = QString::fromStdString(timeline.prev_batch); + isInitialSync = false; + } + + bottomMessages_.insert(bottomMessages_.end(), + std::make_move_iterator(timeline.events.begin()), + std::make_move_iterator(timeline.events.end())); + + if (!bottomMessages_.empty()) + notifyForLastEvent(findLastViewableEvent(bottomMessages_)); + + // If the current timeline is open and there are messages to be rendered. + if (isVisible() && !bottomMessages_.empty()) { + renderBottomEvents(bottomMessages_); + + // Free up space for new messages. + bottomMessages_.clear(); + + // Send a read receipt for the last event. + if (isActiveWindow()) + readLastEvent(); + } +} + +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, [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(4, 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(this, &TimelineView::messagesRetrieved, this, &TimelineView::addBackwardsEvents); + + connect(this, &TimelineView::messageFailed, this, &TimelineView::handleFailedMessage); + connect(this, &TimelineView::messageSent, this, &TimelineView::updatePendingMessage); + + 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::getMessages() +{ + mtx::http::MessagesOpts opts; + opts.room_id = room_id_.toStdString(); + opts.from = prev_batch_token_.toStdString(); + + http::client()->messages( + opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->error("failed to call /messages ({}): {} - {}", + opts.room_id, + mtx::errors::to_string(err->matrix_error.errcode), + err->matrix_error.error); + return; + } + + emit messagesRetrieved(std::move(res)); + }); +} + +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, + uint64_t origin_server_ts, + TimelineDirection direction) +{ + if (direction == TimelineDirection::Bottom) { + return (lastSender_ != user_id) || + isDateDifference(lastMsgTimestamp_, + QDateTime::fromMSecsSinceEpoch(origin_server_ts)); + } else { + return (firstSender_ != user_id) || + isDateDifference(firstMsgTimestamp_, + QDateTime::fromMSecsSinceEpoch(origin_server_ts)); + } +} + +void +TimelineView::addTimelineItem(QWidget *item, TimelineDirection direction) +{ + const auto newDate = getDate(item); + + if (direction == TimelineDirection::Bottom) { + const auto lastItemPosition = scroll_layout_->count() - 1; + const auto lastItem = scroll_layout_->itemAt(lastItemPosition)->widget(); + + if (lastItem) { + const auto oldDate = getDate(lastItem); + + if (oldDate.daysTo(newDate) != 0) { + auto separator = new DateSeparator(newDate, this); + + if (separator) + scroll_layout_->addWidget(separator); + } + } + + pushTimelineItem(item); + } else { + // The first item (position 0) is a stretch widget that pushes + // the widgets to the bottom of the page. + if (scroll_layout_->count() > 1) { + const auto firstItem = scroll_layout_->itemAt(1)->widget(); + + if (firstItem) { + const auto oldDate = getDate(firstItem); + + if (newDate.daysTo(oldDate) != 0) { + auto separator = new DateSeparator(oldDate); + + if (separator) + scroll_layout_->insertWidget(1, separator); + } + } + } + + scroll_layout_->insertWidget(1, item); + } +} + +void +TimelineView::updatePendingMessage(const std::string &txn_id, const QString &event_id) +{ + nhlog::ui()->info("[{}] message was received by the server", txn_id); + if (!pending_msgs_.isEmpty() && + pending_msgs_.head().txn_id == txn_id) { // We haven't received it yet + auto msg = pending_msgs_.dequeue(); + msg.event_id = event_id; + + if (msg.widget) { + msg.widget->setEventId(event_id); + eventIds_[event_id] = msg.widget; + + // If the response comes after we have received the event from sync + // we've already marked the widget as received. + if (!msg.widget->isReceived()) { + msg.widget->markReceived(msg.is_encrypted); + pending_sent_msgs_.append(msg); + } + } else { + nhlog::ui()->warn("[{}] received message response for invalid widget", + txn_id); + } + } + + sendNextPendingMessage(); +} + +void +TimelineView::addUserMessage(mtx::events::MessageType ty, const QString &body) +{ + auto with_sender = (lastSender_ != local_user_) || isDateDifference(lastMsgTimestamp_); + + TimelineItem *view_item = + new TimelineItem(ty, local_user_, body, with_sender, room_id_, scroll_widget_); + + PendingMessage message; + message.ty = ty; + message.txn_id = http::client()->generate_txn_id(); + message.body = body; + message.widget = view_item; + + try { + message.is_encrypted = cache::client()->isRoomEncrypted(room_id_.toStdString()); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to check encryption status of room {}", e.what()); + view_item->deleteLater(); + + // TODO: Send a notification to the user. + + return; + } + + addTimelineItem(view_item); + + lastMessageDirection_ = TimelineDirection::Bottom; + + saveLastMessageInfo(local_user_, QDateTime::currentDateTime()); + handleNewUserMessage(message); +} + +void +TimelineView::handleNewUserMessage(PendingMessage msg) +{ + pending_msgs_.enqueue(msg); + if (pending_msgs_.size() == 1 && pending_sent_msgs_.isEmpty()) + sendNextPendingMessage(); +} + +void +TimelineView::sendNextPendingMessage() +{ + if (pending_msgs_.size() == 0) + return; + + using namespace mtx::events; + + PendingMessage &m = pending_msgs_.head(); + + nhlog::ui()->info("[{}] sending next queued message", m.txn_id); + + if (m.widget) + m.widget->markSent(); + + if (m.is_encrypted) { + nhlog::ui()->info("[{}] sending encrypted event", m.txn_id); + prepareEncryptedMessage(std::move(m)); + return; + } + + switch (m.ty) { + case mtx::events::MessageType::Audio: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + break; + } + case mtx::events::MessageType::Image: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + break; + } + case mtx::events::MessageType::Video: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + break; + } + case mtx::events::MessageType::File: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + break; + } + case mtx::events::MessageType::Text: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + break; + } + case mtx::events::MessageType::Emote: { + http::client()->send_room_message( + room_id_.toStdString(), + m.txn_id, + toRoomMessage(m), + std::bind(&TimelineView::sendRoomMessageHandler, + this, + m.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + break; + } + default: + nhlog::ui()->warn("cannot send unknown message type: {}", m.body.toStdString()); + break; + } +} + +void +TimelineView::notifyForLastEvent() +{ + auto lastItem = scroll_layout_->itemAt(scroll_layout_->count() - 1); + auto *lastTimelineItem = qobject_cast(lastItem->widget()); + + if (lastTimelineItem) + emit updateLastTimelineMessage(room_id_, lastTimelineItem->descriptionMessage()); + else + nhlog::ui()->warn("cast to TimelineView failed: {}", room_id_.toStdString()); +} + +void +TimelineView::notifyForLastEvent(const TimelineEvent &event) +{ + auto descInfo = utils::getMessageDescription(event, local_user_, room_id_); + + if (!descInfo.timestamp.isEmpty()) + emit updateLastTimelineMessage(room_id_, descInfo); +} + +bool +TimelineView::isPendingMessage(const std::string &txn_id, + const QString &sender, + const QString &local_userid) +{ + if (sender != local_userid) + return false; + + auto match_txnid = [txn_id](const auto &msg) -> bool { return msg.txn_id == txn_id; }; + + return std::any_of(pending_msgs_.cbegin(), pending_msgs_.cend(), match_txnid) || + std::any_of(pending_sent_msgs_.cbegin(), pending_sent_msgs_.cend(), match_txnid); +} + +void +TimelineView::removePendingMessage(const std::string &txn_id) +{ + if (txn_id.empty()) + return; + + for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) { + if (it->txn_id == txn_id) { + int index = std::distance(pending_sent_msgs_.begin(), it); + pending_sent_msgs_.removeAt(index); + + if (pending_sent_msgs_.isEmpty()) + sendNextPendingMessage(); + + nhlog::ui()->info("[{}] removed message with sync", txn_id); + } + } + for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) { + if (it->txn_id == txn_id) { + if (it->widget) + it->widget->markReceived(it->is_encrypted); + + nhlog::ui()->info("[{}] received sync before message response", txn_id); + return; + } + } +} + +void +TimelineView::handleFailedMessage(const std::string &txn_id) +{ + Q_UNUSED(txn_id); + // Note: We do this even if the message has already been echoed. + QTimer::singleShot(2000, 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 +{ + if (!ChatPage::instance()->userSettings()->isReadReceiptsEnabled()) + return; + + const auto eventId = getLastEventId(); + + if (!eventId.isEmpty()) + http::client()->read_event(room_id_.toStdString(), + eventId.toStdString(), + [this, eventId](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn( + "failed to read event ({}, {})", + room_id_.toStdString(), + eventId.toStdString()); + } + }); +} + +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(lastItem->widget()); + + if (lastTimelineItem && !lastTimelineItem->eventId().isEmpty()) + return lastTimelineItem->eventId(); + } + + return QString(""); +} + +void +TimelineView::showEvent(QShowEvent *event) +{ + if (!topMessages_.empty()) { + renderTopEvents(topMessages_); + topMessages_.clear(); + } + + if (!bottomMessages_.empty()) { + renderBottomEvents(bottomMessages_); + bottomMessages_.clear(); + scrollDown(); + } + + toggleScrollDownButton(); + + readLastEvent(); + + QWidget::showEvent(event); +} + +bool +TimelineView::event(QEvent *event) +{ + if (event->type() == QEvent::WindowActivate) + readLastEvent(); + + return QWidget::event(event); +} + +void +TimelineView::toggleScrollDownButton() +{ + 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(); + } +} + +void +TimelineView::removeEvent(const QString &event_id) +{ + if (!eventIds_.contains(event_id)) { + nhlog::ui()->warn("cannot remove widget with unknown event_id: {}", + event_id.toStdString()); + return; + } + + auto removedItem = eventIds_[event_id]; + + // Find the next and the previous widgets in the timeline + auto prevWidget = relativeWidget(removedItem, -1); + auto nextWidget = relativeWidget(removedItem, 1); + + // See if they are timeline items + auto prevItem = qobject_cast(prevWidget); + auto nextItem = qobject_cast(nextWidget); + + // ... or a date separator + auto prevLabel = qobject_cast(prevWidget); + + // If it's a TimelineItem add an avatar. + if (prevItem) { + prevItem->addAvatar(); + } + + if (nextItem) { + nextItem->addAvatar(); + } else if (prevLabel) { + // If there's no chat message after this, and we have a label before us, delete the + // label. + prevLabel->deleteLater(); + } + + // If we deleted the last item in the timeline... + if (!nextItem && prevItem) + saveLastMessageInfo(prevItem->descriptionMessage().userid, + prevItem->descriptionMessage().datetime); + + // If we deleted the first item in the timeline... + if (!prevItem && nextItem) + saveFirstMessageInfo(nextItem->descriptionMessage().userid, + nextItem->descriptionMessage().datetime); + + // If we deleted the only item in the timeline... + if (!prevItem && !nextItem) { + firstSender_.clear(); + firstMsgTimestamp_ = QDateTime(); + lastSender_.clear(); + lastMsgTimestamp_ = QDateTime(); + } + + // Finally remove the event. + removedItem->deleteLater(); + eventIds_.remove(event_id); + + // Update the room list with a view of the last message after + // all events have been processed. + QTimer::singleShot(0, this, [this]() { notifyForLastEvent(); }); +} + +QWidget * +TimelineView::relativeWidget(QWidget *item, int dt) const +{ + int pos = scroll_layout_->indexOf(item); + + if (pos == -1) + return nullptr; + + pos = pos + dt; + + bool isOutOfBounds = (pos <= 0 || pos > scroll_layout_->count() - 1); + + return isOutOfBounds ? nullptr : scroll_layout_->itemAt(pos)->widget(); +} + +TimelineEvent +TimelineView::findFirstViewableEvent(const std::vector &events) +{ + auto it = std::find_if(events.begin(), events.end(), [](const auto &event) { + return mtx::events::EventType::RoomMessage == utils::event_type(event); + }); + + return (it == std::end(events)) ? events.front() : *it; +} + +TimelineEvent +TimelineView::findLastViewableEvent(const std::vector &events) +{ + auto it = std::find_if(events.rbegin(), events.rend(), [](const auto &event) { + return (mtx::events::EventType::RoomMessage == utils::event_type(event)) || + (mtx::events::EventType::RoomEncrypted == utils::event_type(event)); + }); + + return (it == std::rend(events)) ? events.back() : *it; +} + +void +TimelineView::saveMessageInfo(const QString &sender, + uint64_t origin_server_ts, + TimelineDirection direction) +{ + updateLastSender(sender, direction); + + if (direction == TimelineDirection::Bottom) + lastMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); + else + firstMsgTimestamp_ = QDateTime::fromMSecsSinceEpoch(origin_server_ts); +} + +bool +TimelineView::isDateDifference(const QDateTime &first, const QDateTime &second) const +{ + // Check if the dates are in a different day. + if (std::abs(first.daysTo(second)) != 0) + return true; + + const uint64_t diffInSeconds = std::abs(first.msecsTo(second)) / 1000; + constexpr uint64_t fifteenMins = 15 * 60; + + return diffInSeconds > fifteenMins; +} + +void +TimelineView::sendRoomMessageHandler(const std::string &txn_id, + const mtx::responses::EventId &res, + mtx::http::RequestErr err) +{ + if (err) { + const int status_code = static_cast(err->status_code); + nhlog::net()->warn("[{}] failed to send message: {} {}", + txn_id, + err->matrix_error.error, + status_code); + emit messageFailed(txn_id); + return; + } + + emit messageSent(txn_id, QString::fromStdString(res.event_id.to_string())); +} + +template<> +mtx::events::msg::Audio +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::Audio audio; + audio.info.mimetype = m.mime.toStdString(); + audio.info.size = m.media_size; + audio.body = m.filename.toStdString(); + audio.url = m.body.toStdString(); + return audio; +} + +template<> +mtx::events::msg::Image +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::Image image; + image.info.mimetype = m.mime.toStdString(); + image.info.size = m.media_size; + image.body = m.filename.toStdString(); + image.url = m.body.toStdString(); + image.info.h = m.dimensions.height(); + image.info.w = m.dimensions.width(); + return image; +} + +template<> +mtx::events::msg::Video +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::Video video; + video.info.mimetype = m.mime.toStdString(); + video.info.size = m.media_size; + video.body = m.filename.toStdString(); + video.url = m.body.toStdString(); + return video; +} + +template<> +mtx::events::msg::Emote +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::Emote emote; + emote.body = m.body.toStdString(); + return emote; +} + +template<> +mtx::events::msg::File +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::File file; + file.info.mimetype = m.mime.toStdString(); + file.info.size = m.media_size; + file.body = m.filename.toStdString(); + file.url = m.body.toStdString(); + return file; +} + +template<> +mtx::events::msg::Text +toRoomMessage(const PendingMessage &m) +{ + mtx::events::msg::Text text; + text.body = m.body.toStdString(); + return text; +} + +void +TimelineView::prepareEncryptedMessage(const PendingMessage &msg) +{ + const auto room_id = room_id_.toStdString(); + + using namespace mtx::events; + using namespace mtx::identifiers; + + json content; + + // Serialize the message to the plaintext that will be encrypted. + switch (msg.ty) { + case MessageType::Audio: { + content = json(toRoomMessage(msg)); + break; + } + case MessageType::Emote: { + content = json(toRoomMessage(msg)); + break; + } + case MessageType::File: { + content = json(toRoomMessage(msg)); + break; + } + case MessageType::Image: { + content = json(toRoomMessage(msg)); + break; + } + case MessageType::Text: { + content = json(toRoomMessage(msg)); + break; + } + case MessageType::Video: { + content = json(toRoomMessage(msg)); + break; + } + default: + break; + } + + json doc{{"type", "m.room.message"}, {"content", content}, {"room_id", room_id}}; + + try { + // Check if we have already an outbound megolm session then we can use. + if (cache::client()->outboundMegolmSessionExists(room_id)) { + auto data = olm::encrypt_group_message( + room_id, http::client()->device_id(), doc.dump()); + + http::client()->send_room_message( + room_id, + msg.txn_id, + data, + std::bind(&TimelineView::sendRoomMessageHandler, + this, + msg.txn_id, + std::placeholders::_1, + std::placeholders::_2)); + return; + } + + nhlog::ui()->info("creating new outbound megolm session"); + + // Create a new outbound megolm session. + auto outbound_session = olm::client()->init_outbound_group_session(); + const auto session_id = mtx::crypto::session_id(outbound_session.get()); + const auto session_key = mtx::crypto::session_key(outbound_session.get()); + + // TODO: needs to be moved in the lib. + auto megolm_payload = json{{"algorithm", "m.megolm.v1.aes-sha2"}, + {"room_id", room_id}, + {"session_id", session_id}, + {"session_key", session_key}}; + + // Saving the new megolm session. + // TODO: Maybe it's too early to save. + OutboundGroupSessionData session_data; + session_data.session_id = session_id; + session_data.session_key = session_key; + session_data.message_index = 0; // TODO Update me + cache::client()->saveOutboundMegolmSession( + room_id, session_data, std::move(outbound_session)); + + const auto members = cache::client()->roomMembers(room_id); + nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id); + + auto keeper = std::make_shared( + [megolm_payload, room_id, doc, txn_id = msg.txn_id, this]() { + try { + auto data = olm::encrypt_group_message( + room_id, http::client()->device_id(), doc.dump()); + + http::client() + ->send_room_message( + room_id, + txn_id, + data, + std::bind(&TimelineView::sendRoomMessageHandler, + this, + txn_id, + std::placeholders::_1, + std::placeholders::_2)); + + } catch (const lmdb::error &e) { + nhlog::db()->critical( + "failed to save megolm outbound session: {}", e.what()); + } + }); + + mtx::requests::QueryKeys req; + for (const auto &member : members) + req.device_keys[member] = {}; + + http::client()->query_keys( + req, + [keeper = std::move(keeper), megolm_payload, this]( + const mtx::responses::QueryKeys &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to query device keys: {} {}", + err->matrix_error.error, + static_cast(err->status_code)); + // TODO: Mark the event as failed. Communicate with the UI. + return; + } + + for (const auto &user : res.device_keys) { + // Mapping from a device_id with valid identity keys to the + // generated room_key event used for sharing the megolm session. + std::map room_key_msgs; + std::map deviceKeys; + + room_key_msgs.clear(); + deviceKeys.clear(); + + for (const auto &dev : user.second) { + const auto user_id = UserId(dev.second.user_id); + const auto device_id = DeviceId(dev.second.device_id); + + const auto device_keys = dev.second.keys; + const auto curveKey = "curve25519:" + device_id.get(); + const auto edKey = "ed25519:" + device_id.get(); + + if ((device_keys.find(curveKey) == device_keys.end()) || + (device_keys.find(edKey) == device_keys.end())) { + nhlog::net()->info( + "ignoring malformed keys for device {}", + device_id.get()); + continue; + } + + DevicePublicKeys pks; + pks.ed25519 = device_keys.at(edKey); + pks.curve25519 = device_keys.at(curveKey); + + try { + if (!mtx::crypto::verify_identity_signature( + json(dev.second), device_id, user_id)) { + nhlog::crypto()->warn( + "failed to verify identity keys: {}", + json(dev.second).dump(2)); + continue; + } + } catch (const json::exception &e) { + nhlog::crypto()->warn( + "failed to parse device key json: {}", + e.what()); + continue; + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->warn( + "failed to verify device key json: {}", + e.what()); + continue; + } + + auto room_key = olm::client() + ->create_room_key_event( + user_id, pks.ed25519, megolm_payload) + .dump(); + + room_key_msgs.emplace(device_id, room_key); + deviceKeys.emplace(device_id, pks); + } + + std::vector valid_devices; + valid_devices.reserve(room_key_msgs.size()); + for (auto const &d : room_key_msgs) { + valid_devices.push_back(d.first); + + nhlog::net()->info("{}", d.first); + nhlog::net()->info(" curve25519 {}", + deviceKeys.at(d.first).curve25519); + nhlog::net()->info(" ed25519 {}", + deviceKeys.at(d.first).ed25519); + } + + nhlog::net()->info( + "sending claim request for user {} with {} devices", + user.first, + valid_devices.size()); + + http::client()->claim_keys( + user.first, + valid_devices, + std::bind(&TimelineView::handleClaimedKeys, + this, + keeper, + room_key_msgs, + deviceKeys, + user.first, + std::placeholders::_1, + std::placeholders::_2)); + + // TODO: Wait before sending the next batch of requests. + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + }); + + // TODO: Let the user know about the errors. + } catch (const lmdb::error &e) { + nhlog::db()->critical( + "failed to open outbound megolm session ({}): {}", room_id, e.what()); + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical( + "failed to open outbound megolm session ({}): {}", room_id, e.what()); + } +} + +void +TimelineView::handleClaimedKeys(std::shared_ptr keeper, + const std::map &room_keys, + const std::map &pks, + const std::string &user_id, + const mtx::responses::ClaimKeys &res, + mtx::http::RequestErr err) +{ + if (err) { + nhlog::net()->warn("claim keys error: {} {} {}", + err->matrix_error.error, + err->parse_error, + static_cast(err->status_code)); + return; + } + + nhlog::net()->info("claimed keys for {}", user_id); + + if (res.one_time_keys.size() == 0) { + nhlog::net()->info("no one-time keys found for user_id: {}", user_id); + return; + } + + if (res.one_time_keys.find(user_id) == res.one_time_keys.end()) { + nhlog::net()->info("no one-time keys found for user_id: {}", user_id); + return; + } + + auto retrieved_devices = res.one_time_keys.at(user_id); + + // Payload with all the to_device message to be sent. + json body; + body["messages"][user_id] = json::object(); + + for (const auto &rd : retrieved_devices) { + const auto device_id = rd.first; + nhlog::net()->info("{} : \n {}", device_id, rd.second.dump(2)); + + // TODO: Verify signatures + auto otk = rd.second.begin()->at("key"); + + if (pks.find(device_id) == pks.end()) { + nhlog::net()->critical("couldn't find public key for device: {}", + device_id); + continue; + } + + auto id_key = pks.at(device_id).curve25519; + auto s = olm::client()->create_outbound_session(id_key, otk); + + if (room_keys.find(device_id) == room_keys.end()) { + nhlog::net()->critical("couldn't find m.room_key for device: {}", + device_id); + continue; + } + + auto device_msg = olm::client()->create_olm_encrypted_content( + s.get(), room_keys.at(device_id), pks.at(device_id).curve25519); + + try { + cache::client()->saveOlmSession(id_key, std::move(s)); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to save outbound olm session: {}", e.what()); + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical("failed to pickle outbound olm session: {}", + e.what()); + } + + body["messages"][user_id][device_id] = device_msg; + } + + nhlog::net()->info("send_to_device: {}", user_id); + + http::client()->send_to_device( + "m.room.encrypted", body, [keeper](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to send " + "send_to_device " + "message: {}", + err->matrix_error.error); + } + + (void)keeper; + }); +} diff --git a/src/timeline/TimelineView.h b/src/timeline/TimelineView.h new file mode 100644 index 00000000..7b269063 --- /dev/null +++ b/src/timeline/TimelineView.h @@ -0,0 +1,426 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "MatrixClient.h" +#include "timeline/TimelineItem.h" +#include "ui/ScrollBar.h" + +class StateKeeper +{ +public: + StateKeeper(std::function &&fn) + : fn_(std::move(fn)) + {} + + ~StateKeeper() { fn_(); } + +private: + std::function fn_; +}; + +struct DecryptionResult +{ + //! The decrypted content as a normal plaintext event. + utils::TimelineEvent event; + //! Whether or not the decryption was successful. + bool isDecrypted = false; +}; + +class FloatingButton; +struct DescInfo; + +// Contains info about a message shown in the history view +// but not yet confirmed by the homeserver through sync. +struct PendingMessage +{ + mtx::events::MessageType ty; + std::string txn_id; + QString body; + QString filename; + QString mime; + uint64_t media_size; + QString event_id; + TimelineItem *widget; + QSize dimensions; + bool is_encrypted = false; +}; + +template +MessageT +toRoomMessage(const PendingMessage &) = delete; + +template<> +mtx::events::msg::Audio +toRoomMessage(const PendingMessage &m); + +template<> +mtx::events::msg::Emote +toRoomMessage(const PendingMessage &m); + +template<> +mtx::events::msg::File +toRoomMessage(const PendingMessage &); + +template<> +mtx::events::msg::Image +toRoomMessage(const PendingMessage &m); + +template<> +mtx::events::msg::Text +toRoomMessage(const PendingMessage &); + +template<> +mtx::events::msg::Video +toRoomMessage(const PendingMessage &m); + +// In which place new TimelineItems should be inserted. +enum class TimelineDirection +{ + Top, + Bottom, +}; + +class TimelineView : public QWidget +{ + Q_OBJECT + +public: + TimelineView(const mtx::responses::Timeline &timeline, + const QString &room_id, + QWidget *parent = 0); + TimelineView(const QString &room_id, QWidget *parent = 0); + + // Add new events at the end of the timeline. + void addEvents(const mtx::responses::Timeline &timeline); + void addUserMessage(mtx::events::MessageType ty, const QString &msg); + + template + void addUserMessage(const QString &url, + const QString &filename, + const QString &mime, + uint64_t size, + const QSize &dimensions = QSize()); + void updatePendingMessage(const std::string &txn_id, const QString &event_id); + void scrollDown(); + + //! Remove an item from the timeline with the given Event ID. + void removeEvent(const QString &event_id); + void setPrevBatchToken(const QString &token) { prev_batch_token_ = token; } + +public slots: + void sliderRangeChanged(int min, int max); + void sliderMoved(int position); + void fetchHistory(); + + // Add old events at the top of the timeline. + void addBackwardsEvents(const mtx::responses::Messages &msgs); + + // Whether or not the initial batch has been loaded. + bool hasLoaded() { return scroll_layout_->count() > 1 || isTimelineFinished; } + + void handleFailedMessage(const std::string &txn_id); + +private slots: + void sendNextPendingMessage(); + +signals: + void updateLastTimelineMessage(const QString &user, const DescInfo &info); + void messagesRetrieved(const mtx::responses::Messages &res); + void messageFailed(const std::string &txn_id); + void messageSent(const std::string &txn_id, const QString &event_id); + +protected: + void paintEvent(QPaintEvent *event) override; + void showEvent(QShowEvent *event) override; + bool event(QEvent *event) override; + +private: + using TimelineEvent = mtx::events::collections::TimelineEvents; + + QWidget *relativeWidget(QWidget *item, int dt) const; + + DecryptionResult parseEncryptedEvent( + const mtx::events::EncryptedEvent &e); + + void handleClaimedKeys(std::shared_ptr keeper, + const std::map &room_key, + const std::map &pks, + const std::string &user_id, + const mtx::responses::ClaimKeys &res, + mtx::http::RequestErr err); + + //! Callback for all message sending. + void sendRoomMessageHandler(const std::string &txn_id, + const mtx::responses::EventId &res, + mtx::http::RequestErr err); + void prepareEncryptedMessage(const PendingMessage &msg); + + //! Call the /messages endpoint to fill the timeline. + void getMessages(); + //! HACK: Fixing layout flickering when adding to the bottom + //! of the timeline. + void pushTimelineItem(QWidget *item) + { + item->hide(); + scroll_layout_->addWidget(item); + QTimer::singleShot(0, this, [item]() { item->show(); }); + }; + + //! Decides whether or not to show or hide the scroll down button. + void toggleScrollDownButton(); + void init(); + void addTimelineItem(QWidget *item, + TimelineDirection direction = TimelineDirection::Bottom); + void updateLastSender(const QString &user_id, TimelineDirection direction); + void notifyForLastEvent(); + void notifyForLastEvent(const TimelineEvent &event); + //! Keep track of the sender and the timestamp of the current message. + void saveLastMessageInfo(const QString &sender, const QDateTime &datetime) + { + lastSender_ = sender; + lastMsgTimestamp_ = datetime; + } + void saveFirstMessageInfo(const QString &sender, const QDateTime &datetime) + { + firstSender_ = sender; + firstMsgTimestamp_ = datetime; + } + //! Keep track of the sender and the timestamp of the current message. + void saveMessageInfo(const QString &sender, + uint64_t origin_server_ts, + TimelineDirection direction); + + TimelineEvent findFirstViewableEvent(const std::vector &events); + TimelineEvent findLastViewableEvent(const std::vector &events); + + //! Mark the last event as read. + void readLastEvent() const; + //! Whether or not the scrollbar is visible (non-zero height). + bool isScrollbarActivated() { return scroll_area_->verticalScrollBar()->value() != 0; } + //! Retrieve the event id of the last item. + QString getLastEventId() const; + + template + TimelineItem *processMessageEvent(const Event &event, TimelineDirection direction); + + // TODO: Remove this eventually. + template + TimelineItem *processMessageEvent(const Event &event, TimelineDirection direction); + + // For events with custom display widgets. + template + TimelineItem *createTimelineItem(const Event &event, bool withSender); + + // For events without custom display widgets. + // TODO: All events should have custom widgets. + template + TimelineItem *createTimelineItem(const Event &event, bool withSender); + + // Used to determine whether or not we should prefix a message with the + // sender's name. + bool isSenderRendered(const QString &user_id, + uint64_t origin_server_ts, + TimelineDirection direction); + + bool isPendingMessage(const std::string &txn_id, + const QString &sender, + const QString &userid); + void removePendingMessage(const std::string &txn_id); + + bool isDuplicate(const QString &event_id) { return eventIds_.contains(event_id); } + + void handleNewUserMessage(PendingMessage msg); + bool isDateDifference(const QDateTime &first, + const QDateTime &second = QDateTime::currentDateTime()) const; + + // Return nullptr if the event couldn't be parsed. + QWidget *parseMessageEvent(const mtx::events::collections::TimelineEvents &event, + TimelineDirection direction); + + //! Store the event id associated with the given widget. + void saveEventId(QWidget *widget); + + QVBoxLayout *top_layout_; + QVBoxLayout *scroll_layout_; + + QScrollArea *scroll_area_; + ScrollBar *scrollbar_; + QWidget *scroll_widget_; + + QString firstSender_; + QDateTime firstMsgTimestamp_; + QString lastSender_; + QDateTime lastMsgTimestamp_; + + QString room_id_; + QString prev_batch_token_; + QString local_user_; + + bool isPaginationInProgress_ = false; + + // Keeps track whether or not the user has visited the view. + bool isInitialized = false; + bool isTimelineFinished = false; + bool isInitialSync = true; + + const int SCROLL_BAR_GAP = 200; + + QTimer *paginationTimer_; + + int scroll_height_ = 0; + int previous_max_height_ = 0; + + int oldPosition_; + int oldHeight_; + + FloatingButton *scrollDownBtn_; + + TimelineDirection lastMessageDirection_; + + //! Messages received by sync not added to the timeline. + std::vector bottomMessages_; + //! Messages received by /messages not added to the timeline. + std::vector topMessages_; + + //! Render the given timeline events to the bottom of the timeline. + void renderBottomEvents(const std::vector &events); + //! Render the given timeline events to the top of the timeline. + void renderTopEvents(const std::vector &events); + + // The events currently rendered. Used for duplicate detection. + QMap eventIds_; + QQueue pending_msgs_; + QList pending_sent_msgs_; +}; + +template +void +TimelineView::addUserMessage(const QString &url, + const QString &filename, + const QString &mime, + uint64_t size, + const QSize &dimensions) +{ + auto with_sender = (lastSender_ != local_user_) || isDateDifference(lastMsgTimestamp_); + auto trimmed = QFileInfo{filename}.fileName(); // Trim file path. + + auto widget = new Widget(url, trimmed, size, this); + + TimelineItem *view_item = + new TimelineItem(widget, local_user_, with_sender, room_id_, scroll_widget_); + + addTimelineItem(view_item); + + lastMessageDirection_ = TimelineDirection::Bottom; + + // Keep track of the sender and the timestamp of the current message. + saveLastMessageInfo(local_user_, QDateTime::currentDateTime()); + + PendingMessage message; + message.ty = MsgType; + message.txn_id = http::client()->generate_txn_id(); + message.body = url; + message.filename = trimmed; + message.mime = mime; + message.media_size = size; + message.widget = view_item; + message.dimensions = dimensions; + + handleNewUserMessage(message); +} + +template +TimelineItem * +TimelineView::createTimelineItem(const Event &event, bool withSender) +{ + TimelineItem *item = new TimelineItem(event, withSender, room_id_, scroll_widget_); + return item; +} + +template +TimelineItem * +TimelineView::createTimelineItem(const Event &event, bool withSender) +{ + auto eventWidget = new Widget(event); + auto item = new TimelineItem(eventWidget, event, withSender, room_id_, scroll_widget_); + + return item; +} + +template +TimelineItem * +TimelineView::processMessageEvent(const Event &event, TimelineDirection direction) +{ + const auto event_id = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + + const auto txn_id = event.unsigned_data.transaction_id; + if ((!txn_id.empty() && isPendingMessage(txn_id, sender, local_user_)) || + isDuplicate(event_id)) { + removePendingMessage(txn_id); + return nullptr; + } + + auto with_sender = isSenderRendered(sender, event.origin_server_ts, direction); + + saveMessageInfo(sender, event.origin_server_ts, direction); + + auto item = createTimelineItem(event, with_sender); + + eventIds_[event_id] = item; + + return item; +} + +template +TimelineItem * +TimelineView::processMessageEvent(const Event &event, TimelineDirection direction) +{ + const auto event_id = QString::fromStdString(event.event_id); + const auto sender = QString::fromStdString(event.sender); + + const auto txn_id = event.unsigned_data.transaction_id; + if ((!txn_id.empty() && isPendingMessage(txn_id, sender, local_user_)) || + isDuplicate(event_id)) { + removePendingMessage(txn_id); + return nullptr; + } + + auto with_sender = isSenderRendered(sender, event.origin_server_ts, direction); + + saveMessageInfo(sender, event.origin_server_ts, direction); + + auto item = createTimelineItem(event, with_sender); + + eventIds_[event_id] = item; + + return item; +} diff --git a/src/timeline/TimelineViewManager.cc b/src/timeline/TimelineViewManager.cc deleted file mode 100644 index c8e00b66..00000000 --- a/src/timeline/TimelineViewManager.cc +++ /dev/null @@ -1,318 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include - -#include -#include -#include - -#include "Cache.h" -#include "Logging.hpp" -#include "timeline/TimelineView.h" -#include "timeline/TimelineViewManager.h" -#include "timeline/widgets/AudioItem.h" -#include "timeline/widgets/FileItem.h" -#include "timeline/widgets/ImageItem.h" -#include "timeline/widgets/VideoItem.h" - -TimelineViewManager::TimelineViewManager(QWidget *parent) - : QStackedWidget(parent) -{ - setStyleSheet("border: none;"); -} - -void -TimelineViewManager::removeTimelineEvent(const QString &room_id, const QString &event_id) -{ - auto view = views_[room_id]; - - if (view) - view->removeEvent(event_id); -} - -void -TimelineViewManager::queueTextMessage(const QString &msg) -{ - if (active_room_.isEmpty()) - return; - - auto room_id = active_room_; - auto view = views_[room_id]; - - view->addUserMessage(mtx::events::MessageType::Text, msg); -} - -void -TimelineViewManager::queueEmoteMessage(const QString &msg) -{ - if (active_room_.isEmpty()) - return; - - auto room_id = active_room_; - auto view = views_[room_id]; - - view->addUserMessage(mtx::events::MessageType::Emote, msg); -} - -void -TimelineViewManager::queueImageMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size, - const QSize &dimensions) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("Cannot send m.image message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage( - url, filename, mime, size, dimensions); -} - -void -TimelineViewManager::queueFileMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.file message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::queueAudioMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.audio message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::queueVideoMessage(const QString &roomid, - const QString &filename, - const QString &url, - const QString &mime, - uint64_t size) -{ - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("cannot send m.video message to a non-managed view"); - return; - } - - auto view = views_[roomid]; - - view->addUserMessage(url, filename, mime, size); -} - -void -TimelineViewManager::initialize(const mtx::responses::Rooms &rooms) -{ - for (auto it = rooms.join.cbegin(); it != rooms.join.cend(); ++it) { - addRoom(it->second, QString::fromStdString(it->first)); - } - - sync(rooms); -} - -void -TimelineViewManager::initWithMessages(const std::map &msgs) -{ - for (auto it = msgs.cbegin(); it != msgs.cend(); ++it) { - if (timelineViewExists(it->first)) - return; - - // Create a history view with the room events. - TimelineView *view = new TimelineView(it->second, it->first); - views_.emplace(it->first, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, - this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); - } -} - -void -TimelineViewManager::initialize(const std::vector &rooms) -{ - for (const auto &roomid : rooms) - addRoom(QString::fromStdString(roomid)); -} - -void -TimelineViewManager::addRoom(const mtx::responses::JoinedRoom &room, const QString &room_id) -{ - if (timelineViewExists(room_id)) - return; - - // Create a history view with the room events. - TimelineView *view = new TimelineView(room.timeline, room_id); - views_.emplace(room_id, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, - this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); -} - -void -TimelineViewManager::addRoom(const QString &room_id) -{ - if (timelineViewExists(room_id)) - return; - - // Create a history view without any events. - TimelineView *view = new TimelineView(room_id); - views_.emplace(room_id, QSharedPointer(view)); - - connect(view, - &TimelineView::updateLastTimelineMessage, - this, - &TimelineViewManager::updateRoomsLastMessage); - - // Add the view in the widget stack. - addWidget(view); -} - -void -TimelineViewManager::sync(const mtx::responses::Rooms &rooms) -{ - for (const auto &room : rooms.join) { - auto roomid = QString::fromStdString(room.first); - - if (!timelineViewExists(roomid)) { - nhlog::ui()->warn("ignoring event from unknown room: {}", - roomid.toStdString()); - continue; - } - - auto view = views_.at(roomid); - - view->addEvents(room.second.timeline); - } -} - -void -TimelineViewManager::setHistoryView(const QString &room_id) -{ - if (!timelineViewExists(room_id)) { - nhlog::ui()->warn("room from RoomList is not present in ViewManager: {}", - room_id.toStdString()); - return; - } - - active_room_ = room_id; - auto view = views_.at(room_id); - - setCurrentWidget(view.data()); - - view->fetchHistory(); - view->scrollDown(); -} - -QString -TimelineViewManager::chooseRandomColor() -{ - std::random_device random_device; - std::mt19937 engine{random_device()}; - std::uniform_real_distribution 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(); -} - -bool -TimelineViewManager::hasLoaded() const -{ - return std::all_of(views_.cbegin(), views_.cend(), [](const auto &view) { - return view.second->hasLoaded(); - }); -} diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp new file mode 100644 index 00000000..1decab35 --- /dev/null +++ b/src/timeline/TimelineViewManager.cpp @@ -0,0 +1,318 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include +#include +#include + +#include "Cache.h" +#include "Logging.h" +#include "timeline/TimelineView.h" +#include "timeline/TimelineViewManager.h" +#include "timeline/widgets/AudioItem.h" +#include "timeline/widgets/FileItem.h" +#include "timeline/widgets/ImageItem.h" +#include "timeline/widgets/VideoItem.h" + +TimelineViewManager::TimelineViewManager(QWidget *parent) + : QStackedWidget(parent) +{ + setStyleSheet("border: none;"); +} + +void +TimelineViewManager::removeTimelineEvent(const QString &room_id, const QString &event_id) +{ + auto view = views_[room_id]; + + if (view) + view->removeEvent(event_id); +} + +void +TimelineViewManager::queueTextMessage(const QString &msg) +{ + if (active_room_.isEmpty()) + return; + + auto room_id = active_room_; + auto view = views_[room_id]; + + view->addUserMessage(mtx::events::MessageType::Text, msg); +} + +void +TimelineViewManager::queueEmoteMessage(const QString &msg) +{ + if (active_room_.isEmpty()) + return; + + auto room_id = active_room_; + auto view = views_[room_id]; + + view->addUserMessage(mtx::events::MessageType::Emote, msg); +} + +void +TimelineViewManager::queueImageMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t size, + const QSize &dimensions) +{ + if (!timelineViewExists(roomid)) { + nhlog::ui()->warn("Cannot send m.image message to a non-managed view"); + return; + } + + auto view = views_[roomid]; + + view->addUserMessage( + url, filename, mime, size, dimensions); +} + +void +TimelineViewManager::queueFileMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t size) +{ + if (!timelineViewExists(roomid)) { + nhlog::ui()->warn("cannot send m.file message to a non-managed view"); + return; + } + + auto view = views_[roomid]; + + view->addUserMessage(url, filename, mime, size); +} + +void +TimelineViewManager::queueAudioMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t size) +{ + if (!timelineViewExists(roomid)) { + nhlog::ui()->warn("cannot send m.audio message to a non-managed view"); + return; + } + + auto view = views_[roomid]; + + view->addUserMessage(url, filename, mime, size); +} + +void +TimelineViewManager::queueVideoMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t size) +{ + if (!timelineViewExists(roomid)) { + nhlog::ui()->warn("cannot send m.video message to a non-managed view"); + return; + } + + auto view = views_[roomid]; + + view->addUserMessage(url, filename, mime, size); +} + +void +TimelineViewManager::initialize(const mtx::responses::Rooms &rooms) +{ + for (auto it = rooms.join.cbegin(); it != rooms.join.cend(); ++it) { + addRoom(it->second, QString::fromStdString(it->first)); + } + + sync(rooms); +} + +void +TimelineViewManager::initWithMessages(const std::map &msgs) +{ + for (auto it = msgs.cbegin(); it != msgs.cend(); ++it) { + if (timelineViewExists(it->first)) + return; + + // Create a history view with the room events. + TimelineView *view = new TimelineView(it->second, it->first); + views_.emplace(it->first, QSharedPointer(view)); + + connect(view, + &TimelineView::updateLastTimelineMessage, + this, + &TimelineViewManager::updateRoomsLastMessage); + + // Add the view in the widget stack. + addWidget(view); + } +} + +void +TimelineViewManager::initialize(const std::vector &rooms) +{ + for (const auto &roomid : rooms) + addRoom(QString::fromStdString(roomid)); +} + +void +TimelineViewManager::addRoom(const mtx::responses::JoinedRoom &room, const QString &room_id) +{ + if (timelineViewExists(room_id)) + return; + + // Create a history view with the room events. + TimelineView *view = new TimelineView(room.timeline, room_id); + views_.emplace(room_id, QSharedPointer(view)); + + connect(view, + &TimelineView::updateLastTimelineMessage, + this, + &TimelineViewManager::updateRoomsLastMessage); + + // Add the view in the widget stack. + addWidget(view); +} + +void +TimelineViewManager::addRoom(const QString &room_id) +{ + if (timelineViewExists(room_id)) + return; + + // Create a history view without any events. + TimelineView *view = new TimelineView(room_id); + views_.emplace(room_id, QSharedPointer(view)); + + connect(view, + &TimelineView::updateLastTimelineMessage, + this, + &TimelineViewManager::updateRoomsLastMessage); + + // Add the view in the widget stack. + addWidget(view); +} + +void +TimelineViewManager::sync(const mtx::responses::Rooms &rooms) +{ + for (const auto &room : rooms.join) { + auto roomid = QString::fromStdString(room.first); + + if (!timelineViewExists(roomid)) { + nhlog::ui()->warn("ignoring event from unknown room: {}", + roomid.toStdString()); + continue; + } + + auto view = views_.at(roomid); + + view->addEvents(room.second.timeline); + } +} + +void +TimelineViewManager::setHistoryView(const QString &room_id) +{ + if (!timelineViewExists(room_id)) { + nhlog::ui()->warn("room from RoomList is not present in ViewManager: {}", + room_id.toStdString()); + return; + } + + active_room_ = room_id; + auto view = views_.at(room_id); + + setCurrentWidget(view.data()); + + view->fetchHistory(); + view->scrollDown(); +} + +QString +TimelineViewManager::chooseRandomColor() +{ + std::random_device random_device; + std::mt19937 engine{random_device()}; + std::uniform_real_distribution 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(); +} + +bool +TimelineViewManager::hasLoaded() const +{ + return std::all_of(views_.cbegin(), views_.cend(), [](const auto &view) { + return view.second->hasLoaded(); + }); +} diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h new file mode 100644 index 00000000..f3c099c1 --- /dev/null +++ b/src/timeline/TimelineViewManager.h @@ -0,0 +1,94 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include + +class QFile; + +class RoomInfoListItem; +class TimelineView; +struct DescInfo; +struct SavedMessages; + +class TimelineViewManager : public QStackedWidget +{ + Q_OBJECT + +public: + TimelineViewManager(QWidget *parent); + + // Initialize with timeline events. + void initialize(const mtx::responses::Rooms &rooms); + // Empty initialization. + void initialize(const std::vector &rooms); + + void addRoom(const mtx::responses::JoinedRoom &room, const QString &room_id); + void addRoom(const QString &room_id); + + void sync(const mtx::responses::Rooms &rooms); + void clearAll() { views_.clear(); } + + // Check if all the timelines have been loaded. + bool hasLoaded() const; + + static QString chooseRandomColor(); + +signals: + void clearRoomMessageCount(QString roomid); + void updateRoomsLastMessage(const QString &user, const DescInfo &info); + +public slots: + void removeTimelineEvent(const QString &room_id, const QString &event_id); + void initWithMessages(const std::map &msgs); + + void setHistoryView(const QString &room_id); + void queueTextMessage(const QString &msg); + void queueEmoteMessage(const QString &msg); + void queueImageMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t dsize, + const QSize &dimensions); + void queueFileMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t dsize); + void queueAudioMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t dsize); + void queueVideoMessage(const QString &roomid, + const QString &filename, + const QString &url, + const QString &mime, + uint64_t dsize); + +private: + //! Check if the given room id is managed by a TimelineView. + bool timelineViewExists(const QString &id) { return views_.find(id) != views_.end(); } + + QString active_room_; + std::map> views_; +}; diff --git a/src/timeline/widgets/AudioItem.cc b/src/timeline/widgets/AudioItem.cc deleted file mode 100644 index 2ed4f4c0..00000000 --- a/src/timeline/widgets/AudioItem.cc +++ /dev/null @@ -1,230 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include - -#include "Logging.hpp" -#include "MatrixClient.h" -#include "Utils.h" - -#include "timeline/widgets/AudioItem.h" - -constexpr int MaxWidth = 400; -constexpr int Height = 70; -constexpr int IconRadius = 22; -constexpr int IconDiameter = IconRadius * 2; -constexpr int HorizontalPadding = 12; -constexpr int TextPadding = 15; -constexpr int ActionIconRadius = IconRadius - 4; - -constexpr double VerticalPadding = Height - 2 * IconRadius; -constexpr double IconYCenter = Height / 2; -constexpr double IconXCenter = HorizontalPadding + IconRadius; - -void -AudioItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - playIcon_.addFile(":/icons/icons/ui/play-sign.png"); - pauseIcon_.addFile(":/icons/icons/ui/pause-symbol.png"); - - player_ = new QMediaPlayer; - player_->setMedia(QUrl(url_)); - player_->setVolume(100); - player_->setNotifyInterval(1000); - - connect(this, &AudioItem::fileDownloadedCb, this, &AudioItem::fileDownloaded); - connect(player_, &QMediaPlayer::stateChanged, this, [this](QMediaPlayer::State state) { - if (state == QMediaPlayer::StoppedState) { - state_ = AudioState::Play; - player_->setMedia(QUrl(url_)); - update(); - } - }); - - setFixedHeight(Height); -} - -AudioItem::AudioItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QUrl(QString::fromStdString(event.content.url))} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); -} - -AudioItem::AudioItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} - -QSize -AudioItem::sizeHint() const -{ - return QSize(MaxWidth, Height); -} - -void -AudioItem::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)) { - if (state_ == AudioState::Play) { - state_ = AudioState::Pause; - player_->play(); - } else { - state_ = AudioState::Play; - player_->pause(); - } - - update(); - } else { - filenameToSave_ = QFileDialog::getSaveFileName(this, tr("Save File"), text_); - - if (filenameToSave_.isEmpty()) - return; - - http::client()->download( - url_.toString().toStdString(), - [this](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->info("failed to retrieve m.audio content: {}", - url_.toString().toStdString()); - return; - } - - emit fileDownloadedCb(QByteArray(data.data(), data.size())); - }); - } -} - -void -AudioItem::fileDownloaded(const QByteArray &data) -{ - try { - QFile file(filenameToSave_); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("error while saving file: {}", e.what()); - } -} - -void -AudioItem::resizeEvent(QResizeEvent *event) -{ - QFont font; - font.setPixelSize(12); - font.setWeight(80); - - QFontMetrics fm(font); - const int computedWidth = std::min( - fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); - - resize(computedWidth, Height); - - event->accept(); -} - -void -AudioItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font; - font.setPixelSize(12); - font.setWeight(80); - - QFontMetrics fm(font); - - QPainterPath path; - path.addRoundedRect(QRectF(0, 0, width(), 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); - - QIcon icon_; - if (state_ == AudioState::Play) - icon_ = playIcon_; - else - icon_ = pauseIcon_; - - icon_.paint(&painter, - QRect(IconXCenter - ActionIconRadius / 2, - IconYCenter - ActionIconRadius / 2, - ActionIconRadius, - ActionIconRadius), - 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, width() - 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/AudioItem.cpp b/src/timeline/widgets/AudioItem.cpp new file mode 100644 index 00000000..1e3eb0f0 --- /dev/null +++ b/src/timeline/widgets/AudioItem.cpp @@ -0,0 +1,230 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include + +#include "Logging.h" +#include "MatrixClient.h" +#include "Utils.h" + +#include "timeline/widgets/AudioItem.h" + +constexpr int MaxWidth = 400; +constexpr int Height = 70; +constexpr int IconRadius = 22; +constexpr int IconDiameter = IconRadius * 2; +constexpr int HorizontalPadding = 12; +constexpr int TextPadding = 15; +constexpr int ActionIconRadius = IconRadius - 4; + +constexpr double VerticalPadding = Height - 2 * IconRadius; +constexpr double IconYCenter = Height / 2; +constexpr double IconXCenter = HorizontalPadding + IconRadius; + +void +AudioItem::init() +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + playIcon_.addFile(":/icons/icons/ui/play-sign.png"); + pauseIcon_.addFile(":/icons/icons/ui/pause-symbol.png"); + + player_ = new QMediaPlayer; + player_->setMedia(QUrl(url_)); + player_->setVolume(100); + player_->setNotifyInterval(1000); + + connect(this, &AudioItem::fileDownloadedCb, this, &AudioItem::fileDownloaded); + connect(player_, &QMediaPlayer::stateChanged, this, [this](QMediaPlayer::State state) { + if (state == QMediaPlayer::StoppedState) { + state_ = AudioState::Play; + player_->setMedia(QUrl(url_)); + update(); + } + }); + + setFixedHeight(Height); +} + +AudioItem::AudioItem(const mtx::events::RoomEvent &event, QWidget *parent) + : QWidget(parent) + , url_{QUrl(QString::fromStdString(event.content.url))} + , text_{QString::fromStdString(event.content.body)} + , event_{event} +{ + readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); + + init(); +} + +AudioItem::AudioItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{filename} +{ + readableFileSize_ = utils::humanReadableFileSize(size); + + init(); +} + +QSize +AudioItem::sizeHint() const +{ + return QSize(MaxWidth, Height); +} + +void +AudioItem::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)) { + if (state_ == AudioState::Play) { + state_ = AudioState::Pause; + player_->play(); + } else { + state_ = AudioState::Play; + player_->pause(); + } + + update(); + } else { + filenameToSave_ = QFileDialog::getSaveFileName(this, tr("Save File"), text_); + + if (filenameToSave_.isEmpty()) + return; + + http::client()->download( + url_.toString().toStdString(), + [this](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->info("failed to retrieve m.audio content: {}", + url_.toString().toStdString()); + return; + } + + emit fileDownloadedCb(QByteArray(data.data(), data.size())); + }); + } +} + +void +AudioItem::fileDownloaded(const QByteArray &data) +{ + try { + QFile file(filenameToSave_); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(data); + file.close(); + } catch (const std::exception &e) { + nhlog::ui()->warn("error while saving file: {}", e.what()); + } +} + +void +AudioItem::resizeEvent(QResizeEvent *event) +{ + QFont font; + font.setPixelSize(12); + font.setWeight(80); + + QFontMetrics fm(font); + const int computedWidth = std::min( + fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); + + resize(computedWidth, Height); + + event->accept(); +} + +void +AudioItem::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QFont font; + font.setPixelSize(12); + font.setWeight(80); + + QFontMetrics fm(font); + + QPainterPath path; + path.addRoundedRect(QRectF(0, 0, width(), 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); + + QIcon icon_; + if (state_ == AudioState::Play) + icon_ = playIcon_; + else + icon_ = pauseIcon_; + + icon_.paint(&painter, + QRect(IconXCenter - ActionIconRadius / 2, + IconYCenter - ActionIconRadius / 2, + ActionIconRadius, + ActionIconRadius), + 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, width() - 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/AudioItem.h b/src/timeline/widgets/AudioItem.h new file mode 100644 index 00000000..7b0781a2 --- /dev/null +++ b/src/timeline/widgets/AudioItem.h @@ -0,0 +1,107 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +class AudioItem : public QWidget +{ + Q_OBJECT + + Q_PROPERTY(QColor textColor WRITE setTextColor READ textColor) + Q_PROPERTY(QColor iconColor WRITE setIconColor READ iconColor) + Q_PROPERTY(QColor backgroundColor WRITE setBackgroundColor READ backgroundColor) + + Q_PROPERTY(QColor durationBackgroundColor WRITE setDurationBackgroundColor READ + durationBackgroundColor) + Q_PROPERTY(QColor durationForegroundColor WRITE setDurationForegroundColor READ + durationForegroundColor) + +public: + AudioItem(const mtx::events::RoomEvent &event, + QWidget *parent = nullptr); + + AudioItem(const QString &url, + const QString &filename, + uint64_t size, + QWidget *parent = nullptr); + + QSize sizeHint() const override; + + void setTextColor(const QColor &color) { textColor_ = color; } + void setIconColor(const QColor &color) { iconColor_ = color; } + void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } + + void setDurationBackgroundColor(const QColor &color) { durationBgColor_ = color; } + void setDurationForegroundColor(const QColor &color) { durationFgColor_ = color; } + + QColor textColor() const { return textColor_; } + QColor iconColor() const { return iconColor_; } + QColor backgroundColor() const { return backgroundColor_; } + + QColor durationBackgroundColor() const { return durationBgColor_; } + QColor durationForegroundColor() const { return durationFgColor_; } + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + +signals: + void fileDownloadedCb(const QByteArray &data); + +private slots: + void fileDownloaded(const QByteArray &data); + +private: + void init(); + + enum class AudioState + { + Play, + Pause, + }; + + AudioState state_ = AudioState::Play; + + QUrl url_; + QString text_; + QString readableFileSize_; + QString filenameToSave_; + + mtx::events::RoomEvent event_; + + QMediaPlayer *player_; + + QIcon playIcon_; + QIcon pauseIcon_; + + QColor textColor_ = QColor("white"); + QColor iconColor_ = QColor("#38A3D8"); + QColor backgroundColor_ = QColor("#333"); + + QColor durationBgColor_ = QColor("black"); + QColor durationFgColor_ = QColor("blue"); +}; diff --git a/src/timeline/widgets/FileItem.cc b/src/timeline/widgets/FileItem.cc deleted file mode 100644 index b4555b2f..00000000 --- a/src/timeline/widgets/FileItem.cc +++ /dev/null @@ -1,216 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include - -#include "Logging.hpp" -#include "MatrixClient.h" -#include "Utils.h" - -#include "timeline/widgets/FileItem.h" - -constexpr int MaxWidth = 400; -constexpr int Height = 70; -constexpr int IconRadius = 22; -constexpr int IconDiameter = IconRadius * 2; -constexpr int HorizontalPadding = 12; -constexpr int TextPadding = 15; -constexpr int DownloadIconRadius = IconRadius - 4; - -constexpr double VerticalPadding = Height - 2 * IconRadius; -constexpr double IconYCenter = Height / 2; -constexpr double IconXCenter = HorizontalPadding + IconRadius; - -void -FileItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); - - setFixedHeight(Height); - - connect(this, &FileItem::fileDownloadedCb, this, &FileItem::fileDownloaded); -} - -FileItem::FileItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QString::fromStdString(event.content.url)} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); -} - -FileItem::FileItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} - -void -FileItem::openUrl() -{ - if (url_.toString().isEmpty()) - return; - - auto urlToOpen = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); - - if (!QDesktopServices::openUrl(urlToOpen)) - nhlog::ui()->warn("Could not open url: {}", urlToOpen.toStdString()); -} - -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; - - http::client()->download( - url_.toString().toStdString(), - [this](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::ui()->warn("failed to retrieve m.file content: {}", - url_.toString().toStdString()); - return; - } - - emit fileDownloadedCb(QByteArray(data.data(), data.size())); - }); - } else { - openUrl(); - } -} - -void -FileItem::fileDownloaded(const QByteArray &data) -{ - try { - QFile file(filenameToSave_); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("Error while saving file to: {}", e.what()); - } -} - -void -FileItem::resizeEvent(QResizeEvent *event) -{ - QFont font; - font.setPixelSize(12); - font.setWeight(80); - - QFontMetrics fm(font); - const int computedWidth = std::min( - fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); - - resize(computedWidth, Height); - - event->accept(); -} - -void -FileItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font; - font.setPixelSize(12); - font.setWeight(80); - - QFontMetrics fm(font); - - QPainterPath path; - path.addRoundedRect(QRectF(0, 0, width(), 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, width() - 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/FileItem.cpp b/src/timeline/widgets/FileItem.cpp new file mode 100644 index 00000000..f8d3272d --- /dev/null +++ b/src/timeline/widgets/FileItem.cpp @@ -0,0 +1,216 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include + +#include "Logging.h" +#include "MatrixClient.h" +#include "Utils.h" + +#include "timeline/widgets/FileItem.h" + +constexpr int MaxWidth = 400; +constexpr int Height = 70; +constexpr int IconRadius = 22; +constexpr int IconDiameter = IconRadius * 2; +constexpr int HorizontalPadding = 12; +constexpr int TextPadding = 15; +constexpr int DownloadIconRadius = IconRadius - 4; + +constexpr double VerticalPadding = Height - 2 * IconRadius; +constexpr double IconYCenter = Height / 2; +constexpr double IconXCenter = HorizontalPadding + IconRadius; + +void +FileItem::init() +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + icon_.addFile(":/icons/icons/ui/arrow-pointing-down.png"); + + setFixedHeight(Height); + + connect(this, &FileItem::fileDownloadedCb, this, &FileItem::fileDownloaded); +} + +FileItem::FileItem(const mtx::events::RoomEvent &event, QWidget *parent) + : QWidget(parent) + , url_{QString::fromStdString(event.content.url)} + , text_{QString::fromStdString(event.content.body)} + , event_{event} +{ + readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); + + init(); +} + +FileItem::FileItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{filename} +{ + readableFileSize_ = utils::humanReadableFileSize(size); + + init(); +} + +void +FileItem::openUrl() +{ + if (url_.toString().isEmpty()) + return; + + auto urlToOpen = utils::mxcToHttp( + url_, QString::fromStdString(http::client()->server()), http::client()->port()); + + if (!QDesktopServices::openUrl(urlToOpen)) + nhlog::ui()->warn("Could not open url: {}", urlToOpen.toStdString()); +} + +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; + + http::client()->download( + url_.toString().toStdString(), + [this](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::ui()->warn("failed to retrieve m.file content: {}", + url_.toString().toStdString()); + return; + } + + emit fileDownloadedCb(QByteArray(data.data(), data.size())); + }); + } else { + openUrl(); + } +} + +void +FileItem::fileDownloaded(const QByteArray &data) +{ + try { + QFile file(filenameToSave_); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(data); + file.close(); + } catch (const std::exception &e) { + nhlog::ui()->warn("Error while saving file to: {}", e.what()); + } +} + +void +FileItem::resizeEvent(QResizeEvent *event) +{ + QFont font; + font.setPixelSize(12); + font.setWeight(80); + + QFontMetrics fm(font); + const int computedWidth = std::min( + fm.width(text_) + 2 * IconRadius + VerticalPadding * 2 + TextPadding, (double)MaxWidth); + + resize(computedWidth, Height); + + event->accept(); +} + +void +FileItem::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QFont font; + font.setPixelSize(12); + font.setWeight(80); + + QFontMetrics fm(font); + + QPainterPath path; + path.addRoundedRect(QRectF(0, 0, width(), 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, width() - 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/FileItem.h b/src/timeline/widgets/FileItem.h new file mode 100644 index 00000000..66543e79 --- /dev/null +++ b/src/timeline/widgets/FileItem.h @@ -0,0 +1,82 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include + +class FileItem : public QWidget +{ + Q_OBJECT + + Q_PROPERTY(QColor textColor WRITE setTextColor READ textColor) + Q_PROPERTY(QColor iconColor WRITE setIconColor READ iconColor) + Q_PROPERTY(QColor backgroundColor WRITE setBackgroundColor READ backgroundColor) + +public: + FileItem(const mtx::events::RoomEvent &event, + QWidget *parent = nullptr); + + FileItem(const QString &url, + const QString &filename, + uint64_t size, + QWidget *parent = nullptr); + + QSize sizeHint() const override; + + void setTextColor(const QColor &color) { textColor_ = color; } + void setIconColor(const QColor &color) { iconColor_ = color; } + void setBackgroundColor(const QColor &color) { backgroundColor_ = color; } + + QColor textColor() const { return textColor_; } + QColor iconColor() const { return iconColor_; } + QColor backgroundColor() const { return backgroundColor_; } + +signals: + void fileDownloadedCb(const QByteArray &data); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private slots: + void fileDownloaded(const QByteArray &data); + +private: + void openUrl(); + void init(); + + QUrl url_; + QString text_; + QString readableFileSize_; + QString filenameToSave_; + + mtx::events::RoomEvent event_; + + QIcon icon_; + + QColor textColor_ = QColor("white"); + QColor iconColor_ = QColor("#38A3D8"); + QColor backgroundColor_ = QColor("#333"); +}; diff --git a/src/timeline/widgets/ImageItem.cc b/src/timeline/widgets/ImageItem.cc deleted file mode 100644 index b7adb0fa..00000000 --- a/src/timeline/widgets/ImageItem.cc +++ /dev/null @@ -1,259 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include -#include -#include -#include -#include -#include - -#include "Config.h" -#include "Logging.hpp" -#include "MatrixClient.h" -#include "Utils.h" -#include "dialogs/ImageOverlay.h" -#include "timeline/widgets/ImageItem.h" - -void -ImageItem::downloadMedia(const QUrl &url) -{ - http::client()->download(url.toString().toStdString(), - [this, url](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn( - "failed to retrieve image {}: {} {}", - url.toString().toStdString(), - err->matrix_error.error, - static_cast(err->status_code)); - return; - } - - QPixmap img; - img.loadFromData(QByteArray(data.data(), data.size())); - emit imageDownloaded(img); - }); -} - -void -ImageItem::saveImage(const QString &filename, const QByteArray &data) -{ - try { - QFile file(filename); - - if (!file.open(QIODevice::WriteOnly)) - return; - - file.write(data); - file.close(); - } catch (const std::exception &e) { - nhlog::ui()->warn("Error while saving file to: {}", e.what()); - } -} - -void -ImageItem::init() -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setAttribute(Qt::WA_Hover, true); - - connect(this, &ImageItem::imageDownloaded, this, &ImageItem::setImage); - connect(this, &ImageItem::imageSaved, this, &ImageItem::saveImage); - downloadMedia(url_); -} - -ImageItem::ImageItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , event_{event} -{ - url_ = QString::fromStdString(event.content.url); - text_ = QString::fromStdString(event.content.body); - - init(); -} - -ImageItem::ImageItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - Q_UNUSED(size); - init(); -} - -void -ImageItem::openUrl() -{ - if (url_.toString().isEmpty()) - return; - - auto urlToOpen = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); - - if (!QDesktopServices::openUrl(urlToOpen)) - nhlog::ui()->warn("could not open url: {}", urlToOpen.toStdString()); -} - -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; - scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); - - width_ = scaled_image_.width(); - height_ = scaled_image_.height(); - - setFixedSize(width_, height_); - update(); -} - -void -ImageItem::mousePressEvent(QMouseEvent *event) -{ - if (!isInteractive_) { - event->accept(); - return; - } - - if (event->button() != Qt::LeftButton) - return; - - if (image_.isNull()) { - openUrl(); - return; - } - - if (textRegion_.contains(event->pos())) { - openUrl(); - } else { - auto imgDialog = new dialogs::ImageOverlay(image_); - imgDialog->show(); - } -} - -void -ImageItem::resizeEvent(QResizeEvent *event) -{ - if (!image_) - return QWidget::resizeEvent(event); - - scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); - - width_ = scaled_image_.width(); - height_ = scaled_image_.height(); - - setFixedSize(width_, height_); -} - -void -ImageItem::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QFont font("Open Sans"); - font.setPixelSize(conf::fontSize); - - QFontMetrics metrics(font); - const int fontHeight = metrics.height() + metrics.ascent(); - - if (image_.isNull()) { - QString elidedText = metrics.elidedText(text_, Qt::ElideRight, max_width_ - 10); - - setFixedSize(metrics.width(elidedText), fontHeight); - - painter.setFont(font); - painter.setPen(QPen(QColor(66, 133, 244))); - painter.drawText(QPoint(0, fontHeight / 2), elidedText); - - return; - } - - imageRegion_ = QRectF(0, 0, width_, height_); - - QPainterPath path; - path.addRoundedRect(imageRegion_, 5, 5); - - painter.setPen(Qt::NoPen); - painter.fillPath(path, scaled_image_); - painter.drawPath(path); - - // Bottom text section - if (isInteractive_ && underMouse()) { - const int textBoxHeight = fontHeight / 2 + 6; - - textRegion_ = QRectF(0, height_ - textBoxHeight, width_, textBoxHeight); - - QPainterPath textPath; - textPath.addRoundedRect(textRegion_, 0, 0); - - painter.fillPath(textPath, QColor(40, 40, 40, 140)); - - QString elidedText = metrics.elidedText(text_, Qt::ElideRight, width_ - 10); - - font.setWeight(80); - painter.setFont(font); - painter.setPen(QPen(QColor("white"))); - - textRegion_.adjust(5, 0, 5, 0); - painter.drawText(textRegion_, Qt::AlignVCenter, elidedText); - } -} - -void -ImageItem::saveAs() -{ - auto filename = QFileDialog::getSaveFileName(this, tr("Save image"), text_); - - if (filename.isEmpty()) - return; - - const auto url = url_.toString().toStdString(); - - http::client()->download( - url, - [this, filename, url](const std::string &data, - const std::string &, - const std::string &, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to retrieve image {}: {} {}", - url, - err->matrix_error.error, - static_cast(err->status_code)); - return; - } - - emit imageSaved(filename, QByteArray(data.data(), data.size())); - }); -} diff --git a/src/timeline/widgets/ImageItem.cpp b/src/timeline/widgets/ImageItem.cpp new file mode 100644 index 00000000..19b445db --- /dev/null +++ b/src/timeline/widgets/ImageItem.cpp @@ -0,0 +1,259 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "Config.h" +#include "ImageItem.h" +#include "Logging.h" +#include "MatrixClient.h" +#include "Utils.h" +#include "dialogs/ImageOverlay.h" + +void +ImageItem::downloadMedia(const QUrl &url) +{ + http::client()->download(url.toString().toStdString(), + [this, url](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn( + "failed to retrieve image {}: {} {}", + url.toString().toStdString(), + err->matrix_error.error, + static_cast(err->status_code)); + return; + } + + QPixmap img; + img.loadFromData(QByteArray(data.data(), data.size())); + emit imageDownloaded(img); + }); +} + +void +ImageItem::saveImage(const QString &filename, const QByteArray &data) +{ + try { + QFile file(filename); + + if (!file.open(QIODevice::WriteOnly)) + return; + + file.write(data); + file.close(); + } catch (const std::exception &e) { + nhlog::ui()->warn("Error while saving file to: {}", e.what()); + } +} + +void +ImageItem::init() +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); + setAttribute(Qt::WA_Hover, true); + + connect(this, &ImageItem::imageDownloaded, this, &ImageItem::setImage); + connect(this, &ImageItem::imageSaved, this, &ImageItem::saveImage); + downloadMedia(url_); +} + +ImageItem::ImageItem(const mtx::events::RoomEvent &event, QWidget *parent) + : QWidget(parent) + , event_{event} +{ + url_ = QString::fromStdString(event.content.url); + text_ = QString::fromStdString(event.content.body); + + init(); +} + +ImageItem::ImageItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{filename} +{ + Q_UNUSED(size); + init(); +} + +void +ImageItem::openUrl() +{ + if (url_.toString().isEmpty()) + return; + + auto urlToOpen = utils::mxcToHttp( + url_, QString::fromStdString(http::client()->server()), http::client()->port()); + + if (!QDesktopServices::openUrl(urlToOpen)) + nhlog::ui()->warn("could not open url: {}", urlToOpen.toStdString()); +} + +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; + scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); + + width_ = scaled_image_.width(); + height_ = scaled_image_.height(); + + setFixedSize(width_, height_); + update(); +} + +void +ImageItem::mousePressEvent(QMouseEvent *event) +{ + if (!isInteractive_) { + event->accept(); + return; + } + + if (event->button() != Qt::LeftButton) + return; + + if (image_.isNull()) { + openUrl(); + return; + } + + if (textRegion_.contains(event->pos())) { + openUrl(); + } else { + auto imgDialog = new dialogs::ImageOverlay(image_); + imgDialog->show(); + } +} + +void +ImageItem::resizeEvent(QResizeEvent *event) +{ + if (!image_) + return QWidget::resizeEvent(event); + + scaled_image_ = utils::scaleDown(max_width_, max_height_, image_); + + width_ = scaled_image_.width(); + height_ = scaled_image_.height(); + + setFixedSize(width_, height_); +} + +void +ImageItem::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QFont font("Open Sans"); + font.setPixelSize(conf::fontSize); + + QFontMetrics metrics(font); + const int fontHeight = metrics.height() + metrics.ascent(); + + if (image_.isNull()) { + QString elidedText = metrics.elidedText(text_, Qt::ElideRight, max_width_ - 10); + + setFixedSize(metrics.width(elidedText), fontHeight); + + painter.setFont(font); + painter.setPen(QPen(QColor(66, 133, 244))); + painter.drawText(QPoint(0, fontHeight / 2), elidedText); + + return; + } + + imageRegion_ = QRectF(0, 0, width_, height_); + + QPainterPath path; + path.addRoundedRect(imageRegion_, 5, 5); + + painter.setPen(Qt::NoPen); + painter.fillPath(path, scaled_image_); + painter.drawPath(path); + + // Bottom text section + if (isInteractive_ && underMouse()) { + const int textBoxHeight = fontHeight / 2 + 6; + + textRegion_ = QRectF(0, height_ - textBoxHeight, width_, textBoxHeight); + + QPainterPath textPath; + textPath.addRoundedRect(textRegion_, 0, 0); + + painter.fillPath(textPath, QColor(40, 40, 40, 140)); + + QString elidedText = metrics.elidedText(text_, Qt::ElideRight, width_ - 10); + + font.setWeight(80); + painter.setFont(font); + painter.setPen(QPen(QColor("white"))); + + textRegion_.adjust(5, 0, 5, 0); + painter.drawText(textRegion_, Qt::AlignVCenter, elidedText); + } +} + +void +ImageItem::saveAs() +{ + auto filename = QFileDialog::getSaveFileName(this, tr("Save image"), text_); + + if (filename.isEmpty()) + return; + + const auto url = url_.toString().toStdString(); + + http::client()->download( + url, + [this, filename, url](const std::string &data, + const std::string &, + const std::string &, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to retrieve image {}: {} {}", + url, + err->matrix_error.error, + static_cast(err->status_code)); + return; + } + + emit imageSaved(filename, QByteArray(data.data(), data.size())); + }); +} diff --git a/src/timeline/widgets/ImageItem.h b/src/timeline/widgets/ImageItem.h new file mode 100644 index 00000000..e9d823f4 --- /dev/null +++ b/src/timeline/widgets/ImageItem.h @@ -0,0 +1,108 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include + +namespace dialogs { +class ImageOverlay; +} + +class ImageItem : public QWidget +{ + Q_OBJECT +public: + ImageItem(const mtx::events::RoomEvent &event, + QWidget *parent = nullptr); + + ImageItem(const QString &url, + const QString &filename, + uint64_t size, + QWidget *parent = nullptr); + + QSize sizeHint() const override; + +public slots: + //! Show a save as dialog for the image. + void saveAs(); + void setImage(const QPixmap &image); + void saveImage(const QString &filename, const QByteArray &data); + +signals: + void imageDownloaded(const QPixmap &img); + void imageSaved(const QString &filename, const QByteArray &data); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + + //! Whether the user can interact with the displayed image. + bool isInteractive_ = true; + +private: + void init(); + void openUrl(); + void downloadMedia(const QUrl &url); + + int max_width_ = 500; + int max_height_ = 300; + + int width_; + int height_; + + QPixmap scaled_image_; + QPixmap image_; + + QUrl url_; + QString text_; + + int bottom_height_ = 30; + + QRectF textRegion_; + QRectF imageRegion_; + + mtx::events::RoomEvent event_; +}; + +class StickerItem : public ImageItem +{ + Q_OBJECT + +public: + StickerItem(const mtx::events::Sticker &event, QWidget *parent = nullptr) + : ImageItem{QString::fromStdString(event.content.url), + QString::fromStdString(event.content.body), + event.content.info.size, + parent} + , event_{event} + { + isInteractive_ = false; + setCursor(Qt::ArrowCursor); + setMouseTracking(false); + setAttribute(Qt::WA_Hover, false); + } + +private: + mtx::events::Sticker event_; +}; diff --git a/src/timeline/widgets/VideoItem.cc b/src/timeline/widgets/VideoItem.cc deleted file mode 100644 index daf181b2..00000000 --- a/src/timeline/widgets/VideoItem.cc +++ /dev/null @@ -1,66 +0,0 @@ -/* - * nheko Copyright (C) 2017 Konstantinos Sideris - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include - -#include "Config.h" -#include "MatrixClient.h" -#include "Utils.h" -#include "timeline/widgets/VideoItem.h" - -void -VideoItem::init() -{ - url_ = utils::mxcToHttp( - url_, QString::fromStdString(http::client()->server()), http::client()->port()); -} - -VideoItem::VideoItem(const mtx::events::RoomEvent &event, QWidget *parent) - : QWidget(parent) - , url_{QString::fromStdString(event.content.url)} - , text_{QString::fromStdString(event.content.body)} - , event_{event} -{ - readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); - - init(); - - auto layout = new QVBoxLayout(this); - layout->setMargin(0); - layout->setSpacing(0); - - QString link = QString("%2").arg(url_.toString()).arg(text_); - - label_ = new QLabel(link, this); - label_->setMargin(0); - label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - label_->setOpenExternalLinks(true); - label_->setStyleSheet(QString("font-size: %1px;").arg(conf::fontSize)); - - layout->addWidget(label_); -} - -VideoItem::VideoItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) - : QWidget(parent) - , url_{url} - , text_{filename} -{ - readableFileSize_ = utils::humanReadableFileSize(size); - - init(); -} diff --git a/src/timeline/widgets/VideoItem.cpp b/src/timeline/widgets/VideoItem.cpp new file mode 100644 index 00000000..daf181b2 --- /dev/null +++ b/src/timeline/widgets/VideoItem.cpp @@ -0,0 +1,66 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include + +#include "Config.h" +#include "MatrixClient.h" +#include "Utils.h" +#include "timeline/widgets/VideoItem.h" + +void +VideoItem::init() +{ + url_ = utils::mxcToHttp( + url_, QString::fromStdString(http::client()->server()), http::client()->port()); +} + +VideoItem::VideoItem(const mtx::events::RoomEvent &event, QWidget *parent) + : QWidget(parent) + , url_{QString::fromStdString(event.content.url)} + , text_{QString::fromStdString(event.content.body)} + , event_{event} +{ + readableFileSize_ = utils::humanReadableFileSize(event.content.info.size); + + init(); + + auto layout = new QVBoxLayout(this); + layout->setMargin(0); + layout->setSpacing(0); + + QString link = QString("%2").arg(url_.toString()).arg(text_); + + label_ = new QLabel(link, this); + label_->setMargin(0); + label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + label_->setOpenExternalLinks(true); + label_->setStyleSheet(QString("font-size: %1px;").arg(conf::fontSize)); + + layout->addWidget(label_); +} + +VideoItem::VideoItem(const QString &url, const QString &filename, uint64_t size, QWidget *parent) + : QWidget(parent) + , url_{url} + , text_{filename} +{ + readableFileSize_ = utils::humanReadableFileSize(size); + + init(); +} diff --git a/src/timeline/widgets/VideoItem.h b/src/timeline/widgets/VideoItem.h new file mode 100644 index 00000000..26fa1c35 --- /dev/null +++ b/src/timeline/widgets/VideoItem.h @@ -0,0 +1,51 @@ +/* + * nheko Copyright (C) 2017 Konstantinos Sideris + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include + +class VideoItem : public QWidget +{ + Q_OBJECT + +public: + VideoItem(const mtx::events::RoomEvent &event, + QWidget *parent = nullptr); + + VideoItem(const QString &url, + const QString &filename, + uint64_t size, + QWidget *parent = nullptr); + +private: + void init(); + + QUrl url_; + QString text_; + QString readableFileSize_; + + QLabel *label_; + + mtx::events::RoomEvent event_; +}; -- cgit 1.5.1