#include "InputBar.h" #include #include #include #include #include #include #include #include #include #include "Cache.h" #include "CallManager.h" #include "ChatPage.h" #include "CompletionProxyModel.h" #include "Logging.h" #include "MainWindow.h" #include "MatrixClient.h" #include "Olm.h" #include "TimelineModel.h" #include "UserSettingsPage.h" #include "UsersModel.h" #include "Utils.h" #include "dialogs/PlaceCall.h" #include "dialogs/PreviewUploadOverlay.h" #include "emoji/EmojiModel.h" #include "blurhash.hpp" static constexpr size_t INPUT_HISTORY_SIZE = 10; void InputBar::paste(bool fromMouse) { const QMimeData *md = nullptr; if (fromMouse) { if (QGuiApplication::clipboard()->supportsSelection()) { md = QGuiApplication::clipboard()->mimeData(QClipboard::Selection); } } else { md = QGuiApplication::clipboard()->mimeData(QClipboard::Clipboard); } if (!md) return; nhlog::ui()->debug("Got mime formats: {}", md->formats().join(", ").toStdString()); const auto formats = md->formats().filter("/"); const auto image = formats.filter("image/", Qt::CaseInsensitive); const auto audio = formats.filter("audio/", Qt::CaseInsensitive); const auto video = formats.filter("video/", Qt::CaseInsensitive); if (!image.empty() && md->hasImage()) { showPreview(*md, "", image); } else if (!audio.empty()) { showPreview(*md, "", audio); } else if (!video.empty()) { showPreview(*md, "", 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{path}.exists()) { showPreview(*md, path, formats); } else { nhlog::ui()->warn("Clipboard does not contain any valid file paths."); } } else if (md->hasFormat("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("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); } else { nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}", data.join(", ").toStdString()); } } else if (md->hasText()) { emit insertText(md->text()); } else { nhlog::ui()->debug("formats: {}", md->formats().join(", ").toStdString()); } } void InputBar::updateState(int selectionStart_, int selectionEnd_, int cursorPosition_, QString text_) { if (text_.isEmpty()) stopTyping(); else startTyping(); if (text_ != text()) { if (history_.empty()) history_.push_front(text_); else history_.front() = text_; history_index_ = 0; } selectionStart = selectionStart_; selectionEnd = selectionEnd_; cursorPosition = cursorPosition_; } QString InputBar::text() const { if (history_index_ < history_.size()) return history_.at(history_index_); return ""; } QString InputBar::previousText() { history_index_++; if (history_index_ >= INPUT_HISTORY_SIZE) history_index_ = INPUT_HISTORY_SIZE; else if (text().isEmpty()) history_index_--; return text(); } QString InputBar::nextText() { history_index_--; if (history_index_ >= INPUT_HISTORY_SIZE) history_index_ = 0; return text(); } QObject * InputBar::completerFor(QString completerName) { if (completerName == "user") { auto userModel = new UsersModel(room->roomId().toStdString()); auto proxy = new CompletionProxyModel(userModel); userModel->setParent(proxy); return proxy; } else if (completerName == "emoji") { auto emojiModel = new emoji::EmojiModel(); auto proxy = new CompletionProxyModel(emojiModel); emojiModel->setParent(proxy); return proxy; } return nullptr; } void InputBar::send() { if (text().trimmed().isEmpty()) return; if (text().startsWith('/')) { int command_end = text().indexOf(' '); 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 == "/") { message(args); } else { command(name, args); } } else { message(text()); } nhlog::ui()->debug("Send: {}", text().toStdString()); if (history_.size() == INPUT_HISTORY_SIZE) history_.pop_back(); history_.push_front(""); history_index_ = 0; } 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( QString("Error while reading media: %1").arg(file.errorString())); return; } setUploading(true); auto bin = file.readAll(); QMimeData data; data.setData(mime.name(), bin); showPreview(data, fileName, QStringList{mime.name()}); } void InputBar::message(QString msg) { mtx::events::msg::Text text = {}; text.body = msg.trimmed().toStdString(); if (ChatPage::instance()->userSettings()->markdown()) { text.formatted_body = utils::markdownToHtml(msg).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->reply().isEmpty()) { auto related = room->relatedInfo(room->reply()); QString body; bool firstLine = true; for (const auto &line : related.quoted_body.split("\n")) { if (firstLine) { firstLine = false; body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line); } else { body = QString("%1\n> %2\n").arg(body).arg(line); } } text.body = QString("%1\n%2").arg(body).arg(msg).toStdString(); // NOTE(Nico): rich replies always need a formatted_body! text.format = "org.matrix.custom.html"; if (ChatPage::instance()->userSettings()->markdown()) text.formatted_body = utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg)) .toStdString(); else text.formatted_body = utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString(); text.relates_to.in_reply_to.event_id = related.related_event; room->resetReply(); } room->sendMessageEvent(text, mtx::events::EventType::RoomMessage); } void InputBar::emote(QString msg) { auto html = utils::markdownToHtml(msg); 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"; } if (!room->reply().isEmpty()) { emote.relates_to.in_reply_to.event_id = room->reply().toStdString(); room->resetReply(); } room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); } void InputBar::image(const QString &filename, const std::optional &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.relates_to.in_reply_to.event_id = room->reply().toStdString(); room->resetReply(); } room->sendMessageEvent(image, mtx::events::EventType::RoomMessage); } void InputBar::file(const QString &filename, const std::optional &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.relates_to.in_reply_to.event_id = room->reply().toStdString(); room->resetReply(); } room->sendMessageEvent(file, mtx::events::EventType::RoomMessage); } void InputBar::audio(const QString &filename, const std::optional &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.relates_to.in_reply_to.event_id = room->reply().toStdString(); room->resetReply(); } room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); } void InputBar::video(const QString &filename, const std::optional &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.relates_to.in_reply_to.event_id = room->reply().toStdString(); room->resetReply(); } room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); } void InputBar::command(QString command, QString args) { if (command == "me") { emote(args); } else if (command == "join") { ChatPage::instance()->joinRoom(args); } else if (command == "invite") { ChatPage::instance()->inviteUser(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "kick") { ChatPage::instance()->kickUser(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "ban") { ChatPage::instance()->banUser(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "unban") { ChatPage::instance()->unbanUser(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "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, [](mtx::responses::EventId, mtx::http::RequestErr err) { if (err) nhlog::net()->error("Failed to set room displayname: {}", err->matrix_error.error); }); } else if (command == "shrug") { message("¯\\_(ツ)_/¯" + (args.isEmpty() ? "" : " " + args)); } else if (command == "fliptable") { message("(╯°□°)╯︵ ┻━┻"); } else if (command == "unfliptable") { message(" ┯━┯╭( º _ º╭)"); } else if (command == "sovietflip") { message("ノ┬─┬ノ ︵ ( \\o°o)\\"); } else if (command == "clear-timeline") { room->clearTimeline(); } else if (command == "rotate-megolm-session") { cache::dropOutboundMegolmSession(room->roomId().toStdString()); } } void InputBar::showPreview(const QMimeData &source, QString path, const QStringList &formats) { dialogs::PreviewUploadOverlay *previewDialog_ = new dialogs::PreviewUploadOverlay(ChatPage::instance()); previewDialog_->setAttribute(Qt::WA_DeleteOnClose); if (source.hasImage()) previewDialog_->setPreview(qvariant_cast(source.imageData()), formats.front()); else if (!path.isEmpty()) previewDialog_->setPreview(path); else if (!formats.isEmpty()) { 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) { setUploading(true); auto payload = std::string(data.data(), data.size()); std::optional 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.split("/")[0]; nhlog::ui()->debug("Mime: {}", mime.toStdString()); if (mimeClass == "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 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(qRed(p))); data.push_back(static_cast(qGreen(p))); data.push_back(static_cast(qBlue(p))); } } 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(err->status_code)); setUploading(false); return; } auto url = QString::fromStdString(res.content_uri); if (encryptedFile) encryptedFile->url = res.content_uri; if (mimeClass == "image") image(filename, encryptedFile, url, mime, size, dimensions, blurhash); else if (mimeClass == "audio") audio(filename, encryptedFile, url, mime, size); else if (mimeClass == "video") video(filename, encryptedFile, url, mime, size); else file(filename, encryptedFile, url, mime, size); setUploading(false); }); }); } void InputBar::callButton() { auto callManager_ = ChatPage::instance()->callManager(); if (callManager_->onActiveCall()) { callManager_->hangUp(); } else { auto current_room_ = room->roomId(); if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString()); roomInfo.member_count != 2) { ChatPage::instance()->showNotification("Calls are limited to 1:1 rooms."); } else { std::vector members( cache::getMembers(current_room_.toStdString())); const RoomMember &callee = members.front().user_id == utils::localUser() ? members.back() : members.front(); auto dialog = new dialogs::PlaceCall(callee.user_id, callee.display_name, QString::fromStdString(roomInfo.name), QString::fromStdString(roomInfo.avatar_url), ChatPage::instance()->userSettings(), MainWindow::instance()); connect(dialog, &dialogs::PlaceCall::voice, callManager_, [callManager_, current_room_]() { callManager_->sendInvite(current_room_, false); }); connect(dialog, &dialogs::PlaceCall::video, callManager_, [callManager_, current_room_]() { callManager_->sendInvite(current_room_, true); }); utils::centerWidget(dialog, MainWindow::instance()); dialog->show(); } } } 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); } }); }