Skip to content
Snippets Groups Projects
InputBar.cpp 28.6 KiB
Newer Older
Nicolas Werner's avatar
Nicolas Werner committed
// SPDX-FileCopyrightText: 2021 Nheko Contributors
// SPDX-FileCopyrightText: 2022 Nheko Contributors
Nicolas Werner's avatar
Nicolas Werner committed
//
// SPDX-License-Identifier: GPL-3.0-or-later

Nicolas Werner's avatar
Nicolas Werner committed
#include "InputBar.h"

#include <QClipboard>
#include <QDropEvent>
Nicolas Werner's avatar
Nicolas Werner committed
#include <QFileDialog>
Nicolas Werner's avatar
Nicolas Werner committed
#include <QGuiApplication>
#include <QMimeData>
Nicolas Werner's avatar
Nicolas Werner committed
#include <QMimeDatabase>
#include <QStandardPaths>
#include <QTextBoundaryFinder>
Nicolas Werner's avatar
Nicolas Werner committed
#include <QUrl>
LordMZTE's avatar
LordMZTE committed
#include <QRegularExpression>
Nicolas Werner's avatar
Nicolas Werner committed
#include <mtx/responses/common.hpp>
Nicolas Werner's avatar
Nicolas Werner committed
#include <mtx/responses/media.hpp>
Nicolas Werner's avatar
Nicolas Werner committed

#include "Cache.h"
#include "ChatPage.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "CombinedImagePackModel.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "Config.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "Logging.h"
#include "MainWindow.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "MatrixClient.h"
#include "TimelineModel.h"
#include "TimelineViewManager.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "UserSettingsPage.h"
#include "Utils.h"
Nicolas Werner's avatar
Nicolas Werner committed
#include "dialogs/PreviewUploadOverlay.h"

#include "blurhash.hpp"
Nicolas Werner's avatar
Nicolas Werner committed

static constexpr size_t INPUT_HISTORY_SIZE = 10;
Nicolas Werner's avatar
Nicolas Werner committed
void
Nicolas Werner's avatar
Nicolas Werner committed
InputBar::paste(bool fromMouse)
{
    const QMimeData *md = nullptr;
    if (fromMouse && QGuiApplication::clipboard()->supportsSelection()) {
        md = QGuiApplication::clipboard()->mimeData(QClipboard::Selection);
    } else {
        md = QGuiApplication::clipboard()->mimeData(QClipboard::Clipboard);
    }
    if (md)
        insertMimeData(md);
}

void
InputBar::insertMimeData(const QMimeData *md)
{
    if (!md)
        return;

    nhlog::ui()->debug("Got mime formats: {}",
                       md->formats().join(QStringLiteral(", ")).toStdString());
    const auto formats = md->formats().filter(QStringLiteral("/"));
    const auto image   = formats.filter(QStringLiteral("image/"), Qt::CaseInsensitive);
    const auto audio   = formats.filter(QStringLiteral("audio/"), Qt::CaseInsensitive);
    const auto video   = formats.filter(QStringLiteral("video/"), Qt::CaseInsensitive);
        if (formats.contains(QStringLiteral("image/svg+xml"), Qt::CaseInsensitive)) {
            showPreview(*md, QLatin1String(""), QStringList(QStringLiteral("image/svg+xml")));
            showPreview(*md, QLatin1String(""), image);
    } else if (!audio.empty()) {
        showPreview(*md, QLatin1String(""), audio);
    } else if (!video.empty()) {
        showPreview(*md, QLatin1String(""), video);
    } else if (md->hasUrls()) {
        // Generic file path for any platform.
        QString path;
        for (auto &&u : md->urls()) {
            if (u.isLocalFile()) {
                path = u.toLocalFile();
                break;
            }
        }
        if (!path.isEmpty() && QFileInfo::exists(path)) {
            showPreview(*md, path, formats);
        } else {
            nhlog::ui()->warn("Clipboard does not contain any valid file paths.");
        }
    } else if (md->hasFormat(QStringLiteral("x-special/gnome-copied-files"))) {
        // Special case for X11 users. See "Notes for X11 Users" in md.
        // Source: http://doc.qt.io/qt-5/qclipboard.html

        // This MIME type returns a string with multiple lines separated by '\n'. The first
        // line is the command to perform with the clipboard (not useful to us). The
        // following lines are the file URIs.
        //
        // Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function
        // nautilus_clipboard_get_uri_list_from_selection_data()
        // https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c

        auto data = md->data(QStringLiteral("x-special/gnome-copied-files")).split('\n');
        if (data.size() < 2) {
            nhlog::ui()->warn("MIME format is malformed, cannot perform paste.");
            return;
        }
        QString path;
        for (int i = 1; i < data.size(); ++i) {
            QUrl url{data[i]};
            if (url.isLocalFile()) {
                path = url.toLocalFile();
                break;
            }
        }
        if (!path.isEmpty()) {
            showPreview(*md, path, formats);
Nicolas Werner's avatar
Nicolas Werner committed
        } else {
            nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}",
                              data.join(", ").toStdString());
Nicolas Werner's avatar
Nicolas Werner committed
        }
    } else if (md->hasText()) {
        emit insertText(md->text());
    } else {
        nhlog::ui()->debug("formats: {}", md->formats().join(QStringLiteral(", ")).toStdString());
void
InputBar::updateAtRoom(const QString &t)
{
    bool roomMention = false;

    if (t.size() > 4) {
        QTextBoundaryFinder finder(QTextBoundaryFinder::BoundaryType::Word, t);

        finder.toStart();
        do {
            auto start = finder.position();
            finder.toNextBoundary();
            auto end = finder.position();
            if (start > 0 && end - start >= 4 &&
                t.mid(start, end - start) == QLatin1String("room") &&
                t.at(start - 1) == QChar('@')) {
                roomMention = true;
                break;
            }
        } while (finder.position() < t.size());
    }

    if (roomMention != this->containsAtRoom_) {
        this->containsAtRoom_ = roomMention;
        emit containsAtRoomChanged();
    }
InputBar::setText(const QString &newText)
    if (history_.empty())
        history_.push_front(newText);
    else
        history_.front() = newText;
    history_index_ = 0;
    if (history_.size() == INPUT_HISTORY_SIZE)
        history_.pop_back();
    updateAtRoom(QLatin1String(""));
    emit textChanged(newText);
Nicolas Werner's avatar
Nicolas Werner committed
void
InputBar::updateState(int selectionStart_,
                      int selectionEnd_,
                      int cursorPosition_,
                      const QString &text_)
Nicolas Werner's avatar
Nicolas Werner committed
{
    if (text_.isEmpty())
        stopTyping();
    else
        startTyping();
    if (text_ != text()) {
        if (history_.empty())
            history_.push_front(text_);
        else
            history_.front() = text_;
        history_index_ = 0;
        updateAtRoom(text_);
    }
    selectionStart = selectionStart_;
    selectionEnd   = selectionEnd_;
    cursorPosition = cursorPosition_;
}

QString
InputBar::text() const
{
    if (history_index_ < history_.size())
        return history_.at(history_index_);
    return QString();
}

QString
InputBar::previousText()
{
    history_index_++;
    if (history_index_ >= INPUT_HISTORY_SIZE)
        history_index_ = INPUT_HISTORY_SIZE;
    else if (text().isEmpty())
        history_index_--;

    updateAtRoom(text());
    return text();
}

QString
InputBar::nextText()
{
    history_index_--;
    if (history_index_ >= INPUT_HISTORY_SIZE)
        history_index_ = 0;
    updateAtRoom(text());
    return text();
Nicolas Werner's avatar
Nicolas Werner committed
}

void
InputBar::send()
{
    if (text().trimmed().isEmpty())
        return;
    nhlog::ui()->debug("Send: {}", text().toStdString());

    auto wasEdit = !room->edit().isEmpty();

    if (text().startsWith('/')) {
        int command_end = text().indexOf(QRegularExpression(QStringLiteral("\\s")));
        if (command_end == -1)
            command_end = text().size();
        auto name = text().mid(1, command_end - 1);
        auto args = text().mid(command_end + 1);
        if (name.isEmpty() || name == QLatin1String("/")) {
            message(args);
        } else {
            command(name, args);
    } else {
        message(text());
    }

    if (!wasEdit) {
        history_.push_front(QLatin1String(""));
        setText(QLatin1String(""));
Nicolas Werner's avatar
Nicolas Werner committed
}
Nicolas Werner's avatar
Nicolas Werner committed
void
InputBar::openFileSelection()
{
    const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
    const auto fileName      = QFileDialog::getOpenFileName(
      ChatPage::instance(), tr("Select a file"), homeFolder, tr("All Files (*)"));
    if (fileName.isEmpty())
        return;
    QMimeDatabase db;
    QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
    QFile file{fileName};
    if (!file.open(QIODevice::ReadOnly)) {
        emit ChatPage::instance()->showNotification(
          QStringLiteral("Error while reading media: %1").arg(file.errorString()));
    setUploading(true);
    auto bin = file.readAll();
    QMimeData data;
    data.setData(mime.name(), bin);
    showPreview(data, fileName, QStringList{mime.name()});
Nicolas Werner's avatar
Nicolas Werner committed
void
InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbowify)
    mtx::events::msg::Text text = {};
    text.body                   = msg.trimmed().toStdString();

    if ((ChatPage::instance()->userSettings()->markdown() &&
         useMarkdown == MarkdownOverride::NOT_SPECIFIED) ||
        useMarkdown == MarkdownOverride::ON) {
        text.formatted_body = utils::markdownToHtml(msg, rainbowify).toStdString();
        // Remove markdown links by completer
        text.body = msg.trimmed().replace(conf::strings::matrixToMarkdownLink, "\\1").toStdString();

        // Don't send formatted_body, when we don't need to
        if (text.formatted_body.find('<') == std::string::npos)
            text.formatted_body = "";
        else
            text.format = "org.matrix.custom.html";
    }
    if (!room->edit().isEmpty()) {
        if (!room->reply().isEmpty()) {
            text.relations.relations.push_back(
              {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
        text.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});

    } else if (!room->reply().isEmpty()) {
        auto related = room->relatedInfo(room->reply());

        QString body;
        bool firstLine = true;
        auto lines     = related.quoted_body.splitRef(u'\n');
        for (auto line : qAsConst(lines)) {
            if (firstLine) {
                firstLine = false;
                body      = QStringLiteral("> <%1> %2\n").arg(related.quoted_user, line);
            } else {
                body += QStringLiteral("> %1\n").arg(line);
        text.body = QStringLiteral("%1\n%2").arg(body, msg).toStdString();
        // NOTE(Nico): rich replies always need a formatted_body!
        text.format = "org.matrix.custom.html";
        if ((ChatPage::instance()->userSettings()->markdown() &&
             useMarkdown == MarkdownOverride::NOT_SPECIFIED) ||
            useMarkdown == MarkdownOverride::ON)
            text.formatted_body =
              utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg, rainbowify))
                .toStdString();
        else
            text.formatted_body =
              utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
        text.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, related.related_event});
    }
    room->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
InputBar::emote(const QString &msg, bool rainbowify)
    auto html = utils::markdownToHtml(msg, rainbowify);

    mtx::events::msg::Emote emote;
    emote.body = msg.trimmed().toStdString();

    if (html != msg.trimmed().toHtmlEscaped() && ChatPage::instance()->userSettings()->markdown()) {
        emote.formatted_body = html.toStdString();
        emote.format         = "org.matrix.custom.html";
        // Remove markdown links by completer
        emote.body =
          msg.trimmed().replace(conf::strings::matrixToMarkdownLink, "\\1").toStdString();
    }

    if (!room->reply().isEmpty()) {
        emote.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        emote.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
InputBar::notice(const QString &msg, bool rainbowify)
    auto html = utils::markdownToHtml(msg, rainbowify);

    mtx::events::msg::Notice notice;
    notice.body = msg.trimmed().toStdString();

    if (html != msg.trimmed().toHtmlEscaped() && ChatPage::instance()->userSettings()->markdown()) {
        notice.formatted_body = html.toStdString();
        notice.format         = "org.matrix.custom.html";
        // Remove markdown links by completer
        notice.body =
          msg.trimmed().replace(conf::strings::matrixToMarkdownLink, "\\1").toStdString();
    }

    if (!room->reply().isEmpty()) {
        notice.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        notice.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(notice, mtx::events::EventType::RoomMessage);
Nicolas Werner's avatar
Nicolas Werner committed
void
InputBar::image(const QString &filename,
                const std::optional<mtx::crypto::EncryptedFile> &file,
                const QString &url,
                const QString &mime,
                uint64_t dsize,
                const QSize &dimensions,
                const QString &blurhash)
{
    mtx::events::msg::Image image;
    image.info.mimetype = mime.toStdString();
    image.info.size     = dsize;
    image.info.blurhash = blurhash.toStdString();
    image.body          = filename.toStdString();
    image.info.h        = dimensions.height();
    image.info.w        = dimensions.width();

    if (file)
        image.file = file;
    else
        image.url = url.toStdString();

    if (!room->reply().isEmpty()) {
        image.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        image.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
Nicolas Werner's avatar
Nicolas Werner committed
}

void
InputBar::file(const QString &filename,
               const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
               const QString &url,
               const QString &mime,
               uint64_t dsize)
{
    mtx::events::msg::File file;
    file.info.mimetype = mime.toStdString();
    file.info.size     = dsize;
    file.body          = filename.toStdString();

    if (encryptedFile)
        file.file = encryptedFile;
    else
        file.url = url.toStdString();

    if (!room->reply().isEmpty()) {
        file.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        file.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
Nicolas Werner's avatar
Nicolas Werner committed
}

void
InputBar::audio(const QString &filename,
                const std::optional<mtx::crypto::EncryptedFile> &file,
                const QString &url,
                const QString &mime,
                uint64_t dsize)
{
    mtx::events::msg::Audio audio;
    audio.info.mimetype = mime.toStdString();
    audio.info.size     = dsize;
    audio.body          = filename.toStdString();
    audio.url           = url.toStdString();

    if (file)
        audio.file = file;
    else
        audio.url = url.toStdString();

    if (!room->reply().isEmpty()) {
        audio.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        audio.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
Nicolas Werner's avatar
Nicolas Werner committed
}

void
InputBar::video(const QString &filename,
                const std::optional<mtx::crypto::EncryptedFile> &file,
                const QString &url,
                const QString &mime,
                uint64_t dsize)
{
    mtx::events::msg::Video video;
    video.info.mimetype = mime.toStdString();
    video.info.size     = dsize;
    video.body          = filename.toStdString();

    if (file)
        video.file = file;
    else
        video.url = url.toStdString();

    if (!room->reply().isEmpty()) {
        video.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        video.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
Nicolas Werner's avatar
Nicolas Werner committed
void
Nicolas Werner's avatar
Nicolas Werner committed
InputBar::sticker(CombinedImagePackModel *model, int row)
    if (!model || row < 0)
        return;

    auto img = model->imageAt(row);

    mtx::events::msg::StickerImage sticker{};
    sticker.info = img.info.value_or(mtx::common::ImageInfo{});
    sticker.url  = img.url;
    sticker.body = img.body;

    // workaround for https://github.com/vector-im/element-ios/issues/2353
    sticker.info.thumbnail_url           = sticker.url;
    sticker.info.thumbnail_info.mimetype = sticker.info.mimetype;
    sticker.info.thumbnail_info.size     = sticker.info.size;
    sticker.info.thumbnail_info.h        = sticker.info.h;
    sticker.info.thumbnail_info.w        = sticker.info.w;

    if (!room->reply().isEmpty()) {
        sticker.relations.relations.push_back(
          {mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
    }
    if (!room->edit().isEmpty()) {
        sticker.relations.relations.push_back(
          {mtx::common::RelationType::Replace, room->edit().toStdString()});
    }

    room->sendMessageEvent(sticker, mtx::events::EventType::Sticker);
Nicolas Werner's avatar
Nicolas Werner committed
void
InputBar::command(const QString &command, QString args)
    if (command == QLatin1String("me")) {
        emote(args, false);
    } else if (command == QLatin1String("react")) {
        auto eventId = room->reply();
        if (!eventId.isEmpty())
            reaction(eventId, args.trimmed());
    } else if (command == QLatin1String("join")) {
        ChatPage::instance()->joinRoom(args);
    } else if (command == QLatin1String("part") || command == QLatin1String("leave")) {
        ChatPage::instance()->timelineManager()->openLeaveRoomDialog(room->roomId());
    } else if (command == QLatin1String("invite")) {
        ChatPage::instance()->inviteUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
    } else if (command == QLatin1String("kick")) {
        ChatPage::instance()->kickUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
    } else if (command == QLatin1String("ban")) {
        ChatPage::instance()->banUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
    } else if (command == QLatin1String("unban")) {
        ChatPage::instance()->unbanUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
    } else if (command == QLatin1String("roomnick")) {
        mtx::events::state::Member member;
        member.display_name = args.toStdString();
        member.avatar_url =
          cache::avatarUrl(room->roomId(),
                           QString::fromStdString(http::client()->user_id().to_string()))
            .toStdString();
        member.membership = mtx::events::state::Membership::Join;

        http::client()->send_state_event(
          room->roomId().toStdString(),
          http::client()->user_id().to_string(),
          member,
          [](const mtx::responses::EventId &, mtx::http::RequestErr err) {
              if (err)
                  nhlog::net()->error("Failed to set room displayname: {}",
                                      err->matrix_error.error);
          });
    } else if (command == QLatin1String("shrug")) {
        message(\\_(ツ)_/¯" + (args.isEmpty() ? QLatin1String("") : " " + args));
    } else if (command == QLatin1String("fliptable")) {
        message(QStringLiteral("(╯°□°)╯︵ ┻━┻"));
    } else if (command == QLatin1String("unfliptable")) {
        message(QStringLiteral(" ┯━┯╭( º _ º╭)"));
    } else if (command == QLatin1String("sovietflip")) {
        message(QStringLiteral("ノ┬─┬ノ ︵ ( \\o°o)\\"));
    } else if (command == QLatin1String("clear-timeline")) {
        room->clearTimeline();
    } else if (command == QLatin1String("rotate-megolm-session")) {
        cache::dropOutboundMegolmSession(room->roomId().toStdString());
    } else if (command == QLatin1String("md")) {
        message(args, MarkdownOverride::ON);
    } else if (command == QLatin1String("plain")) {
        message(args, MarkdownOverride::OFF);
    } else if (command == QLatin1String("rainbow")) {
        message(args, MarkdownOverride::ON, true);
    } else if (command == QLatin1String("rainbowme")) {
        emote(args, true);
    } else if (command == QLatin1String("notice")) {
        notice(args, false);
    } else if (command == QLatin1String("rainbownotice")) {
        notice(args, true);
    } else if (command == QLatin1String("goto")) {
        // Goto has three different modes:
        // 1 - Going directly to a given event ID
        if (args[0] == '$') {
            room->showEvent(args);
            return;
        }
        // 2 - Going directly to a given message index
        if (args[0] >= '0' && args[0] <= '9') {
            room->showEvent(args);
            return;
        }
        // 3 - Matrix URI handler, as if you clicked the URI
        if (ChatPage::instance()->handleMatrixUri(args)) {
            return;
        nhlog::net()->error("Could not resolve goto: {}", args.toStdString());
    } else if (command == QLatin1String("converttodm")) {
        utils::markRoomAsDirect(this->room->roomId(),
                                cache::getMembers(this->room->roomId().toStdString(), 0, -1));
    } else if (command == QLatin1String("converttoroom")) {
        utils::removeDirectFromRoom(this->room->roomId());
InputBar::showPreview(const QMimeData &source, const QString &path, const QStringList &formats)
Nicolas Werner's avatar
Nicolas Werner committed
{
    auto *previewDialog_ = new dialogs::PreviewUploadOverlay(ChatPage::instance());
    previewDialog_->setAttribute(Qt::WA_DeleteOnClose);

    // Force SVG to _not_ be handled as an image, but as raw data
    if (source.hasImage() &&
        (formats.empty() || formats.front() != QLatin1String("image/svg+xml"))) {
        if (!formats.empty() && formats.front().startsWith(QLatin1String("image/"))) {
            // known format, keep as-is
            previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()), formats.front());
        } else {
            // unknown image format, default to image/png
            previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()),
                                       QStringLiteral("image/png"));
        previewDialog_->setPreview(path);
    else if (!formats.isEmpty()) {
        const auto &mime = formats.first();
        previewDialog_->setPreview(source.data(mime), mime);
    } else {
        setUploading(false);
        previewDialog_->deleteLater();
        return;
    }

    connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() {
        setUploading(false);
    });

    connect(
      previewDialog_,
      &dialogs::PreviewUploadOverlay::confirmUpload,
      this,
      [this](const QByteArray &data, const QString &mime, const QString &fn) {
          if (!data.size()) {
              nhlog::ui()->warn("Attempted to upload zero-byte file?! Mimetype {}, filename {}",
                                mime.toStdString(),
                                fn.toStdString());
              return;
          }
          setUploading(true);

          setText(QLatin1String(""));

          auto payload = std::string(data.data(), data.size());
          std::optional<mtx::crypto::EncryptedFile> encryptedFile;
          if (cache::isRoomEncrypted(room->roomId().toStdString())) {
              mtx::crypto::BinaryBuf buf;
              std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
              payload                      = mtx::crypto::to_string(buf);
          }

          QSize dimensions;
          QString blurhash;
          auto mimeClass = mime.left(mime.indexOf(u'/'));
          nhlog::ui()->debug("Mime: {}", mime.toStdString());
Nicolas Werner's avatar
Nicolas Werner committed
          if (mimeClass == u"image") {
              QImage img = utils::readImage(data);

              dimensions = img.size();
              if (img.height() > 200 && img.width() > 360)
                  img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
              std::vector<unsigned char> data_;
              for (int y = 0; y < img.height(); y++) {
                  for (int x = 0; x < img.width(); x++) {
                      auto p = img.pixel(x, y);
                      data_.push_back(static_cast<unsigned char>(qRed(p)));
                      data_.push_back(static_cast<unsigned char>(qGreen(p)));
                      data_.push_back(static_cast<unsigned char>(qBlue(p)));
Nicolas Werner's avatar
Nicolas Werner committed
                  }
              }
              blurhash = QString::fromStdString(
                blurhash::encode(data_.data(), img.width(), img.height(), 4, 3));
          }

          http::client()->upload(
            payload,
            encryptedFile ? "application/octet-stream" : mime.toStdString(),
            QFileInfo(fn).fileName().toStdString(),
            [this,
             filename      = fn,
             encryptedFile = std::move(encryptedFile),
             mimeClass,
             mime,
             size = payload.size(),
             dimensions,
             blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable {
                if (err) {
                    emit ChatPage::instance()->showNotification(
                      tr("Failed to upload media. Please try again."));
                    nhlog::net()->warn("failed to upload media: {} {} ({})",
                                       err->matrix_error.error,
                                       to_string(err->matrix_error.errcode),
                                       static_cast<int>(err->status_code));
                    setUploading(false);
                    return;
                }
                auto url = QString::fromStdString(res.content_uri);
                if (encryptedFile)
                    encryptedFile->url = res.content_uri;

                    image(filename, encryptedFile, url, mime, size, dimensions, blurhash);
                else if (mimeClass == u"audio")
                    audio(filename, encryptedFile, url, mime, size);
                else if (mimeClass == u"video")
                    video(filename, encryptedFile, url, mime, size);
                else
                    file(filename, encryptedFile, url, mime, size);
                setUploading(false);
            });
      });
Nicolas Werner's avatar
Nicolas Werner committed
}
void
InputBar::startTyping()
{
    if (!typingRefresh_.isActive()) {
        typingRefresh_.start();

        if (ChatPage::instance()->userSettings()->typingNotifications()) {
            http::client()->start_typing(
              room->roomId().toStdString(), 10'000, [](mtx::http::RequestErr err) {
                  if (err) {
                      nhlog::net()->warn("failed to send typing notification: {}",
                                         err->matrix_error.error);
                  }
              });
    }
    typingTimeout_.start();
}
void
InputBar::stopTyping()
{
    typingRefresh_.stop();
    typingTimeout_.stop();
    if (!ChatPage::instance()->userSettings()->typingNotifications())
        return;
    http::client()->stop_typing(room->roomId().toStdString(), [](mtx::http::RequestErr err) {
        if (err) {
            nhlog::net()->warn("failed to stop typing notifications: {}", err->matrix_error.error);
        }
    });

void
InputBar::reaction(const QString &reactedEvent, const QString &reactionKey)
{
    auto reactions = room->reactions(reactedEvent.toStdString());
    QString selfReactedEvent;
    for (const auto &reaction : reactions) {
        if (reactionKey == reaction.key_) {
            selfReactedEvent = reaction.selfReactedEvent_;
            break;
    if (selfReactedEvent.startsWith(QLatin1String("m")))
        return;

    // If selfReactedEvent is empty, that means we haven't previously reacted
    if (selfReactedEvent.isEmpty()) {
        mtx::events::msg::Reaction reaction;
        mtx::common::Relation rel;
        rel.rel_type = mtx::common::RelationType::Annotation;
        rel.event_id = reactedEvent.toStdString();
        rel.key      = reactionKey.toStdString();
        reaction.relations.relations.push_back(rel);

        room->sendMessageEvent(reaction, mtx::events::EventType::Reaction);

        auto recents = UserSettings::instance()->recentReactions();
        if (recents.contains(reactionKey))
            recents.removeOne(reactionKey);
        else if (recents.size() >= 6)
            recents.removeLast();
        recents.push_front(reactionKey);
        UserSettings::instance()->setRecentReactions(recents);
        // Otherwise, we have previously reacted and the reaction should be redacted
    } else {
        room->redactEvent(selfReactedEvent);
    }