matrixion/src/timeline/TimelineView.cc
christarazi cd9d1a2ec6 Support audio, video, generic file for pasting (#220)
* Refactor widget items to use same interface

* Support audio, video, generic file for pasting

* Add utils function for human readable file sizes

* Set correct MIME type for media messages

This change also determines the size of the upload once from the
ContentLengthHeader, rather than seeking the QIODevice and asking for
its size. This prevents any future trouble in case the QIODevice is
sequential (cannot be seeked). The MIME type is also determined at
upload once, rather than using the QIODevice and the underlying data
inside.

* Allow for file urls to be used as fall-back

This fixes an issue on macOS which uses `text/uri-list` for copying
files to the clipboard.

fixes #228
2018-02-18 22:52:31 +02:00

741 lines
25 KiB
C++

/*
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QApplication>
#include <QFileInfo>
#include <QTimer>
#include "Config.h"
#include "FloatingButton.h"
#include "RoomMessages.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;
TimelineView::TimelineView(const mtx::responses::Timeline &timeline,
QSharedPointer<MatrixClient> client,
const QString &room_id,
QWidget *parent)
: QWidget(parent)
, room_id_{room_id}
, client_{client}
{
init();
addEvents(timeline);
}
TimelineView::TimelineView(QSharedPointer<MatrixClient> client,
const QString &room_id,
QWidget *parent)
: QWidget(parent)
, room_id_{room_id}
, client_{client}
{
init();
client_->messages(room_id_, "");
}
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;
client_->messages(room_id_, prev_batch_token_);
paginationTimer_->start(5000);
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.
// TODO: Keep a map of the event ids to filter out duplicates.
if (isPaginationInProgress_)
return;
isPaginationInProgress_ = true;
// FIXME: Maybe move this to TimelineViewManager to remove the
// extra calls?
client_->messages(room_id_, prev_batch_token_);
}
}
void
TimelineView::addBackwardsEvents(const QString &room_id, const mtx::responses::Messages &msgs)
{
if (room_id_ != room_id)
return;
// 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.
for (auto const &e : msgs.chunk) {
if (isViewable(e))
topMessages_.emplace_back(e);
}
// 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(topMessages_.at(0));
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;
}
TimelineItem *
TimelineView::parseMessageEvent(const mtx::events::collections::TimelineEvents &event,
TimelineDirection direction)
{
namespace msg = mtx::events::msg;
using AudioEvent = mtx::events::RoomEvent<msg::Audio>;
using EmoteEvent = mtx::events::RoomEvent<msg::Emote>;
using FileEvent = mtx::events::RoomEvent<msg::File>;
using ImageEvent = mtx::events::RoomEvent<msg::Image>;
using NoticeEvent = mtx::events::RoomEvent<msg::Notice>;
using TextEvent = mtx::events::RoomEvent<msg::Text>;
using VideoEvent = mtx::events::RoomEvent<msg::Video>;
if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Audio>>(event)) {
auto audio = mpark::get<mtx::events::RoomEvent<msg::Audio>>(event);
return processMessageEvent<AudioEvent, AudioItem>(audio, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Emote>>(event)) {
auto emote = mpark::get<mtx::events::RoomEvent<msg::Emote>>(event);
return processMessageEvent<EmoteEvent>(emote, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::File>>(event)) {
auto file = mpark::get<mtx::events::RoomEvent<msg::File>>(event);
return processMessageEvent<FileEvent, FileItem>(file, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Image>>(event)) {
auto image = mpark::get<mtx::events::RoomEvent<msg::Image>>(event);
return processMessageEvent<ImageEvent, ImageItem>(image, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Notice>>(event)) {
auto notice = mpark::get<mtx::events::RoomEvent<msg::Notice>>(event);
return processMessageEvent<NoticeEvent>(notice, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Text>>(event)) {
auto text = mpark::get<mtx::events::RoomEvent<msg::Text>>(event);
return processMessageEvent<TextEvent>(text, direction);
} else if (mpark::holds_alternative<mtx::events::RoomEvent<msg::Video>>(event)) {
auto video = mpark::get<mtx::events::RoomEvent<msg::Video>>(event);
return processMessageEvent<VideoEvent, VideoItem>(video, direction);
}
return nullptr;
}
void
TimelineView::renderBottomEvents(const std::vector<TimelineEvent> &events)
{
int counter = 0;
for (const auto &event : events) {
TimelineItem *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 % 10 == 0)
QApplication::processEvents();
}
}
lastMessageDirection_ = TimelineDirection::Bottom;
QApplication::processEvents();
}
void
TimelineView::renderTopEvents(const std::vector<TimelineEvent> &events)
{
std::vector<TimelineItem *> items;
// Reset the sender of the first message in the timeline
// cause we're about to insert a new one.
firstSender_.clear();
// Parse in reverse order to determine where we should not show sender's
// name.
auto ii = events.size();
while (ii != 0) {
--ii;
TimelineItem *item = parseMessageEvent(events[ii], 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())
lastSender_ = items.at(0)->descriptionMessage().userid;
}
void
TimelineView::addEvents(const mtx::responses::Timeline &timeline)
{
if (isInitialSync) {
prev_batch_token_ = QString::fromStdString(timeline.prev_batch);
isInitialSync = false;
}
for (const auto &e : timeline.events) {
// Save the message if it can be rendered.
if (isViewable(e))
bottomMessages_.push_back(e);
// Calculate notifications.
/* if (isNotifiable(e)) */
/* sendNotification() */
}
if (!bottomMessages_.empty())
notifyForLastEvent(bottomMessages_[bottomMessages_.size() - 1]);
// 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();
}
}
inline bool
TimelineView::isViewable(const TimelineEvent &event) const
{
namespace msg = mtx::events::msg;
return mpark::holds_alternative<mtx::events::RoomEvent<msg::Audio>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Emote>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::File>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Image>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Notice>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Text>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Video>>(event);
}
inline bool
TimelineView::isNotifiable(const TimelineEvent &event) const
{
namespace msg = mtx::events::msg;
if (local_user_ == getEventSender(event))
return false;
return mpark::holds_alternative<mtx::events::RoomEvent<msg::Audio>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Emote>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::File>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Image>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Notice>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Text>>(event) ||
mpark::holds_alternative<mtx::events::RoomEvent<msg::Video>>(event);
}
void
TimelineView::init()
{
QSettings settings;
local_user_ = settings.value("auth/user_id").toString();
QIcon icon;
icon.addFile(":/icons/icons/ui/angle-arrow-down.png");
scrollDownBtn_ = new FloatingButton(icon, this);
scrollDownBtn_->setBackgroundColor(QColor("#F5F5F5"));
scrollDownBtn_->setForegroundColor(QColor("black"));
scrollDownBtn_->hide();
connect(scrollDownBtn_, &QPushButton::clicked, this, [=]() {
const int max = scroll_area_->verticalScrollBar()->maximum();
scroll_area_->verticalScrollBar()->setValue(max);
});
top_layout_ = new QVBoxLayout(this);
top_layout_->setSpacing(0);
top_layout_->setMargin(0);
scroll_area_ = new QScrollArea(this);
scroll_area_->setWidgetResizable(true);
scroll_area_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollbar_ = new ScrollBar(scroll_area_);
scroll_area_->setVerticalScrollBar(scrollbar_);
scroll_widget_ = new QWidget(this);
scroll_layout_ = new QVBoxLayout(scroll_widget_);
scroll_layout_->setContentsMargins(15, 0, 15, 15);
scroll_layout_->addStretch(1);
scroll_layout_->setSpacing(0);
scroll_layout_->setObjectName("timelinescrollarea");
scroll_area_->setWidget(scroll_widget_);
top_layout_->addWidget(scroll_area_);
setLayout(top_layout_);
paginationTimer_ = new QTimer(this);
connect(paginationTimer_, &QTimer::timeout, this, &TimelineView::fetchHistory);
connect(client_.data(),
&MatrixClient::messagesRetrieved,
this,
&TimelineView::addBackwardsEvents);
connect(scroll_area_->verticalScrollBar(),
SIGNAL(valueChanged(int)),
this,
SLOT(sliderMoved(int)));
connect(scroll_area_->verticalScrollBar(),
SIGNAL(rangeChanged(int, int)),
this,
SLOT(sliderRangeChanged(int, int)));
}
void
TimelineView::updateLastSender(const QString &user_id, TimelineDirection direction)
{
if (direction == TimelineDirection::Bottom)
lastSender_ = user_id;
else
firstSender_ = user_id;
}
bool
TimelineView::isSenderRendered(const QString &user_id, TimelineDirection direction)
{
if (direction == TimelineDirection::Bottom)
return lastSender_ != user_id;
else
return firstSender_ != user_id;
}
void
TimelineView::addTimelineItem(TimelineItem *item, TimelineDirection direction)
{
const auto newDate = item->descriptionMessage().datetime;
if (direction == TimelineDirection::Bottom) {
const auto lastItemPosition = scroll_layout_->count() - 1;
auto lastItem =
qobject_cast<TimelineItem *>(scroll_layout_->itemAt(lastItemPosition)->widget());
if (lastItem) {
auto oldDate = lastItem->descriptionMessage().datetime;
if (oldDate.daysTo(newDate) != 0) {
auto separator = createDateSeparator(newDate);
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) {
auto firstItem =
qobject_cast<TimelineItem *>(scroll_layout_->itemAt(1)->widget());
if (firstItem) {
auto oldDate = firstItem->descriptionMessage().datetime;
if (newDate.daysTo(oldDate) != 0) {
auto separator = createDateSeparator(oldDate);
if (separator)
scroll_layout_->insertWidget(1, separator);
}
}
}
scroll_layout_->insertWidget(1, item);
}
}
void
TimelineView::updatePendingMessage(int txn_id, QString event_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);
msg.widget->markReceived();
}
pending_sent_msgs_.append(msg);
}
sendNextPendingMessage();
}
void
TimelineView::addUserMessage(mtx::events::MessageType ty, const QString &body)
{
auto with_sender = lastSender_ != local_user_;
TimelineItem *view_item =
new TimelineItem(ty, local_user_, body, with_sender, scroll_widget_);
addTimelineItem(view_item);
lastMessageDirection_ = TimelineDirection::Bottom;
QApplication::processEvents();
lastSender_ = local_user_;
int txn_id = client_->incrementTransactionId();
PendingMessage message(ty, txn_id, body, "", "", -1, "", view_item);
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;
PendingMessage &m = pending_msgs_.head();
switch (m.ty) {
case mtx::events::MessageType::Audio:
case mtx::events::MessageType::Image:
case mtx::events::MessageType::Video:
case mtx::events::MessageType::File:
// FIXME: Improve the API
client_->sendRoomMessage(
m.ty, m.txn_id, room_id_, m.filename, m.mime, m.media_size, m.body);
break;
default:
client_->sendRoomMessage(m.ty, m.txn_id, room_id_, m.body, m.mime, m.media_size);
break;
}
}
void
TimelineView::notifyForLastEvent()
{
auto lastItem = scroll_layout_->itemAt(scroll_layout_->count() - 1);
auto *lastTimelineItem = qobject_cast<TimelineItem *>(lastItem->widget());
if (lastTimelineItem)
emit updateLastTimelineMessage(room_id_, lastTimelineItem->descriptionMessage());
else
qWarning() << "Cast to TimelineView failed" << room_id_;
}
void
TimelineView::notifyForLastEvent(const TimelineEvent &event)
{
auto descInfo = utils::getMessageDescription(event, local_user_);
if (!descInfo.timestamp.isEmpty())
emit updateLastTimelineMessage(room_id_, descInfo);
}
bool
TimelineView::isPendingMessage(const QString &txnid,
const QString &sender,
const QString &local_userid)
{
if (sender != local_userid)
return false;
for (const auto &msg : pending_msgs_) {
if (QString::number(msg.txn_id) == txnid)
return true;
}
for (const auto &msg : pending_sent_msgs_) {
if (QString::number(msg.txn_id) == txnid)
return true;
}
return false;
}
void
TimelineView::removePendingMessage(const QString &txnid)
{
for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) {
if (QString::number(it->txn_id) == txnid) {
int index = std::distance(pending_sent_msgs_.begin(), it);
pending_sent_msgs_.removeAt(index);
if (pending_sent_msgs_.isEmpty())
sendNextPendingMessage();
return;
}
}
for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) {
if (QString::number(it->txn_id) == txnid) {
int index = std::distance(pending_msgs_.begin(), it);
pending_msgs_.removeAt(index);
return;
}
}
}
void
TimelineView::handleFailedMessage(int txnid)
{
Q_UNUSED(txnid);
// Note: We do this even if the message has already been echoed.
QTimer::singleShot(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
{
const auto eventId = getLastEventId();
if (!eventId.isEmpty())
client_->readEvent(room_id_, eventId);
}
QString
TimelineView::getLastEventId() const
{
auto index = scroll_layout_->count();
// Search backwards for the first event that has a valid event id.
while (index > 0) {
--index;
auto lastItem = scroll_layout_->itemAt(index);
auto *lastTimelineItem = qobject_cast<TimelineItem *>(lastItem->widget());
if (lastTimelineItem && !lastTimelineItem->eventId().isEmpty())
return lastTimelineItem->eventId();
}
return QString("");
}
void
TimelineView::showEvent(QShowEvent *event)
{
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);
}
QLabel *
TimelineView::createDateSeparator(QDateTime datetime)
{
auto now = QDateTime::currentDateTime();
auto days = now.daysTo(datetime);
QString fmt;
QLabel *separator;
if (now.date().year() != datetime.date().year())
fmt = QString("ddd d MMMM yy");
else
fmt = QString("ddd d MMMM");
if (days == 0)
separator = new QLabel(tr("Today"));
else if (std::abs(days) == 1)
separator = new QLabel(tr("Yesterday"));
else
separator = new QLabel(datetime.toString(fmt));
if (separator) {
separator->setStyleSheet(
QString("font-size: %1px").arg(conf::timeline::fonts::dateSeparator));
separator->setAlignment(Qt::AlignCenter);
separator->setContentsMargins(0, 15, 0, 15);
}
return separator;
}
QString
TimelineView::getEventSender(const mtx::events::collections::TimelineEvents &event) const
{
return mpark::visit([](auto msg) { return QString::fromStdString(msg.sender); }, 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();
}
}