#include "TimelineViewManager.h" #include #include #include #include #include #include #include "BlurhashProvider.h" #include "CallManager.h" #include "ChatPage.h" #include "ColorImageProvider.h" #include "DelegateChooser.h" #include "Logging.h" #include "MatrixClient.h" #include "MxcImageProvider.h" #include "UserSettingsPage.h" #include "dialogs/ImageOverlay.h" #include "emoji/EmojiModel.h" #include "emoji/Provider.h" #include //only for debugging Q_DECLARE_METATYPE(mtx::events::collections::TimelineEvents) Q_DECLARE_METATYPE(std::vector) namespace msgs = mtx::events::msg; void DeviceVerificationList::add(QString tran_id) { this->deviceVerificationList.push_back(tran_id); } void DeviceVerificationList::remove(QString tran_id) { this->deviceVerificationList.removeOne(tran_id); } bool DeviceVerificationList::exist(QString tran_id) { return this->deviceVerificationList.contains(tran_id); } void TimelineViewManager::updateEncryptedDescriptions() { auto decrypt = settings->decryptSidebar(); QHash>::iterator i; for (i = models.begin(); i != models.end(); ++i) { auto ptr = i.value(); if (!ptr.isNull()) { ptr->setDecryptDescription(decrypt); ptr->updateLastMessage(); } } } void TimelineViewManager::updateColorPalette() { userColors.clear(); if (settings->theme() == "light") { view->rootContext()->setContextProperty("currentActivePalette", QPalette()); view->rootContext()->setContextProperty("currentInactivePalette", QPalette()); } else if (settings->theme() == "dark") { view->rootContext()->setContextProperty("currentActivePalette", QPalette()); view->rootContext()->setContextProperty("currentInactivePalette", QPalette()); } else { view->rootContext()->setContextProperty("currentActivePalette", QPalette()); view->rootContext()->setContextProperty("currentInactivePalette", nullptr); } } QColor TimelineViewManager::userColor(QString id, QColor background) { if (!userColors.contains(id)) userColors.insert( id, QColor(utils::generateContrastingHexColor(id, background.name()))); return userColors.value(id); } QString TimelineViewManager::userPresence(QString id) const { if (id.isEmpty()) return ""; else return QString::fromStdString( mtx::presence::to_string(cache::presenceState(id.toStdString()))); } QString TimelineViewManager::userStatus(QString id) const { return QString::fromStdString(cache::statusMessage(id.toStdString())); } TimelineViewManager::TimelineViewManager(QSharedPointer userSettings, CallManager *callManager, QWidget *parent) : imgProvider(new MxcImageProvider()) , colorImgProvider(new ColorImageProvider()) , blurhashProvider(new BlurhashProvider()) , callManager_(callManager) , settings(userSettings) { qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject, "im.nheko", 1, 0, "MtxEvent", "Can't instantiate enum!"); qmlRegisterUncreatableMetaObject(verification::staticMetaObject, "im.nheko", 1, 0, "VerificationStatus", "Can't instantiate enum!"); qmlRegisterType("im.nheko", 1, 0, "DelegateChoice"); qmlRegisterType("im.nheko", 1, 0, "DelegateChooser"); qmlRegisterType("im.nheko", 1, 0, "DeviceVerificationFlow"); qmlRegisterUncreatableType( "im.nheko", 1, 0, "UserProfileModel", "UserProfile needs to be instantiated on the C++ side"); qmlRegisterSingletonInstance("im.nheko", 1, 0, "TimelineManager", this); qmlRegisterSingletonInstance("im.nheko", 1, 0, "Settings", settings.data()); qRegisterMetaType(); qRegisterMetaType>(); qmlRegisterType("im.nheko.EmojiModel", 1, 0, "EmojiModel"); qmlRegisterType("im.nheko.EmojiModel", 1, 0, "EmojiProxyModel"); qmlRegisterUncreatableType( "im.nheko.EmojiModel", 1, 0, "QAbstractItemModel", "Used by proxy models"); qmlRegisterUncreatableType( "im.nheko.EmojiModel", 1, 0, "Emoji", "Used by emoji models"); qmlRegisterUncreatableMetaObject(emoji::staticMetaObject, "im.nheko.EmojiModel", 1, 0, "EmojiCategory", "Error: Only enums"); this->dvList = new DeviceVerificationList; #ifdef USE_QUICK_VIEW view = new QQuickView(); container = QWidget::createWindowContainer(view, parent); #else view = new QQuickWidget(parent); container = view; view->setResizeMode(QQuickWidget::SizeRootObjectToView); container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0)) view->quickWindow()->setTextRenderType(QQuickWindow::NativeTextRendering); #endif connect(view, &QQuickWidget::statusChanged, this, [](QQuickWidget::Status status) { nhlog::ui()->debug("Status changed to {}", status); }); #endif container->setMinimumSize(200, 200); view->rootContext()->setContextProperty("deviceVerificationList", this->dvList); updateColorPalette(); view->engine()->addImageProvider("MxcImage", imgProvider); view->engine()->addImageProvider("colorimage", colorImgProvider); view->engine()->addImageProvider("blurhash", blurhashProvider); view->setSource(QUrl("qrc:///qml/TimelineView.qml")); connect(dynamic_cast(parent), &ChatPage::themeChanged, this, &TimelineViewManager::updateColorPalette); connect(dynamic_cast(parent), &ChatPage::decryptSidebarChanged, this, &TimelineViewManager::updateEncryptedDescriptions); connect( dynamic_cast(parent), &ChatPage::recievedRoomDeviceVerificationRequest, this, [this](const mtx::events::RoomEvent &message, TimelineModel *model) { if (!(this->dvList->exist(QString::fromStdString(message.event_id)))) { auto flow = new DeviceVerificationFlow( this, DeviceVerificationFlow::Type::RoomMsg, model); if (std::find(message.content.methods.begin(), message.content.methods.end(), mtx::events::msg::VerificationMethods::SASv1) != message.content.methods.end()) { flow->setEventId(message.event_id); emit newDeviceVerificationRequest( std::move(flow), QString::fromStdString(message.event_id), QString::fromStdString(message.sender), QString::fromStdString(message.content.from_device), true); } else { flow->cancelVerification( DeviceVerificationFlow::Error::UnknownMethod); } } }); connect( dynamic_cast(parent), &ChatPage::recievedDeviceVerificationRequest, this, [this](const mtx::events::msg::KeyVerificationRequest &msg, std::string sender) { if (!(this->dvList->exist(QString::fromStdString(msg.transaction_id.value())))) { auto flow = new DeviceVerificationFlow(this); if (std::find(msg.methods.begin(), msg.methods.end(), mtx::events::msg::VerificationMethods::SASv1) != msg.methods.end()) { emit newDeviceVerificationRequest( std::move(flow), QString::fromStdString(msg.transaction_id.value()), QString::fromStdString(sender), QString::fromStdString(msg.from_device)); } else { flow->cancelVerification( DeviceVerificationFlow::Error::UnknownMethod); } } }); connect( dynamic_cast(parent), &ChatPage::recievedDeviceVerificationStart, this, [this](const mtx::events::msg::KeyVerificationStart &msg, std::string sender) { if (msg.transaction_id.has_value()) { if (!(this->dvList->exist( QString::fromStdString(msg.transaction_id.value())))) { auto flow = new DeviceVerificationFlow(this); flow->canonical_json = nlohmann::json(msg); if ((std::find(msg.key_agreement_protocols.begin(), msg.key_agreement_protocols.end(), "curve25519-hkdf-sha256") != msg.key_agreement_protocols.end()) && (std::find(msg.hashes.begin(), msg.hashes.end(), "sha256") != msg.hashes.end()) && (std::find(msg.message_authentication_codes.begin(), msg.message_authentication_codes.end(), "hmac-sha256") != msg.message_authentication_codes.end())) { if (std::find(msg.short_authentication_string.begin(), msg.short_authentication_string.end(), mtx::events::msg::SASMethods::Emoji) != msg.short_authentication_string.end()) { flow->setMethod( DeviceVerificationFlow::Method::Emoji); } else if (std::find( msg.short_authentication_string.begin(), msg.short_authentication_string.end(), mtx::events::msg::SASMethods::Decimal) != msg.short_authentication_string.end()) { flow->setMethod( DeviceVerificationFlow::Method::Decimal); } else { flow->cancelVerification( DeviceVerificationFlow::Error::UnknownMethod); return; } emit newDeviceVerificationRequest( std::move(flow), QString::fromStdString(msg.transaction_id.value()), QString::fromStdString(sender), QString::fromStdString(msg.from_device)); } else { flow->cancelVerification( DeviceVerificationFlow::Error::UnknownMethod); } } } }); connect(dynamic_cast(parent), &ChatPage::loggedOut, this, [this]() { isInitialSync_ = true; emit initialSyncChanged(true); }); } void TimelineViewManager::sync(const mtx::responses::Rooms &rooms) { for (const auto &[room_id, room] : rooms.join) { // addRoom will only add the room, if it doesn't exist addRoom(QString::fromStdString(room_id)); const auto &room_model = models.value(QString::fromStdString(room_id)); if (!isInitialSync_) connect(room_model.data(), &TimelineModel::newCallEvent, callManager_, &CallManager::syncEvent); room_model->addEvents(room.timeline); if (!isInitialSync_) disconnect(room_model.data(), &TimelineModel::newCallEvent, callManager_, &CallManager::syncEvent); if (ChatPage::instance()->userSettings()->typingNotifications()) { std::vector typing; typing.reserve(room.ephemeral.typing.size()); for (const auto &user : room.ephemeral.typing) { if (user != http::client()->user_id().to_string()) typing.push_back(QString::fromStdString(user)); } room_model->updateTypingUsers(typing); } } this->isInitialSync_ = false; emit initialSyncChanged(false); } void TimelineViewManager::addRoom(const QString &room_id) { if (!models.contains(room_id)) { QSharedPointer newRoom(new TimelineModel(this, room_id)); newRoom->setDecryptDescription(settings->decryptSidebar()); connect(newRoom.data(), &TimelineModel::newEncryptedImage, imgProvider, &MxcImageProvider::addEncryptionInfo); models.insert(room_id, std::move(newRoom)); } } void TimelineViewManager::setHistoryView(const QString &room_id) { nhlog::ui()->info("Trying to activate room {}", room_id.toStdString()); auto room = models.find(room_id); if (room != models.end()) { timeline_ = room.value().data(); emit activeTimelineChanged(timeline_); nhlog::ui()->info("Activated room {}", room_id.toStdString()); } } void TimelineViewManager::openImageOverlay(QString mxcUrl, QString eventId) const { QQuickImageResponse *imgResponse = imgProvider->requestImageResponse(mxcUrl.remove("mxc://"), QSize()); connect(imgResponse, &QQuickImageResponse::finished, this, [this, eventId, imgResponse]() { if (!imgResponse->errorString().isEmpty()) { nhlog::ui()->error("Error when retrieving image for overlay: {}", imgResponse->errorString().toStdString()); return; } auto pixmap = QPixmap::fromImage(imgResponse->textureFactory()->image()); auto imgDialog = new dialogs::ImageOverlay(pixmap); imgDialog->showFullScreen(); connect(imgDialog, &dialogs::ImageOverlay::saving, timeline_, [this, eventId, imgDialog]() { // hide the overlay while presenting the save dialog for better // cross platform support. imgDialog->hide(); if (!timeline_->saveMedia(eventId)) { imgDialog->show(); } else { imgDialog->close(); } }); }); } void TimelineViewManager::openLink(QString link) const { QDesktopServices::openUrl(link); } void TimelineViewManager::updateReadReceipts(const QString &room_id, const std::vector &event_ids) { auto room = models.find(room_id); if (room != models.end()) { room.value()->markEventsAsRead(event_ids); } } void TimelineViewManager::initWithMessages(const std::map &msgs) { for (const auto &e : msgs) { addRoom(e.first); models.value(e.first)->addEvents(e.second); } } void TimelineViewManager::queueTextMessage(const QString &msg) { if (!timeline_) return; mtx::events::msg::Text text = {}; text.body = msg.trimmed().toStdString(); if (settings->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 (!timeline_->reply().isEmpty()) { auto related = timeline_->relatedInfo(timeline_->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 (settings->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; timeline_->resetReply(); } timeline_->sendMessageEvent(text, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueEmoteMessage(const QString &msg) { auto html = utils::markdownToHtml(msg); mtx::events::msg::Emote emote; emote.body = msg.trimmed().toStdString(); if (html != msg.trimmed().toHtmlEscaped() && settings->markdown()) { emote.formatted_body = html.toStdString(); emote.format = "org.matrix.custom.html"; } if (!timeline_->reply().isEmpty()) { emote.relates_to.in_reply_to.event_id = timeline_->reply().toStdString(); timeline_->resetReply(); } if (timeline_) timeline_->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey) { if (!timeline_) return; auto reactions = timeline_->reactions(reactedEvent.toStdString()); QString selfReactedEvent; for (const auto &reaction : reactions) { if (reactionKey == reaction.key_) { selfReactedEvent = reaction.selfReactedEvent_; break; } } if (selfReactedEvent.startsWith("m")) return; // If selfReactedEvent is empty, that means we haven't previously reacted if (selfReactedEvent.isEmpty()) { mtx::events::msg::Reaction reaction; reaction.relates_to.rel_type = mtx::common::RelationType::Annotation; reaction.relates_to.event_id = reactedEvent.toStdString(); reaction.relates_to.key = reactionKey.toStdString(); timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction); // Otherwise, we have previously reacted and the reaction should be redacted } else { timeline_->redactEvent(selfReactedEvent); } } void TimelineViewManager::queueImageMessage(const QString &roomid, 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(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { image.relates_to.in_reply_to.event_id = model->reply().toStdString(); model->resetReply(); } model->sendMessageEvent(image, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueFileMessage( const QString &roomid, 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(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { file.relates_to.in_reply_to.event_id = model->reply().toStdString(); model->resetReply(); } model->sendMessageEvent(file, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueAudioMessage(const QString &roomid, 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(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { audio.relates_to.in_reply_to.event_id = model->reply().toStdString(); model->resetReply(); } model->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueVideoMessage(const QString &roomid, 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(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { video.relates_to.in_reply_to.event_id = model->reply().toStdString(); model->resetReply(); } model->sendMessageEvent(video, mtx::events::EventType::RoomMessage); } void TimelineViewManager::queueCallMessage(const QString &roomid, const mtx::events::msg::CallInvite &callInvite) { models.value(roomid)->sendMessageEvent(callInvite, mtx::events::EventType::CallInvite); } void TimelineViewManager::queueCallMessage(const QString &roomid, const mtx::events::msg::CallCandidates &callCandidates) { models.value(roomid)->sendMessageEvent(callCandidates, mtx::events::EventType::CallCandidates); } void TimelineViewManager::queueCallMessage(const QString &roomid, const mtx::events::msg::CallAnswer &callAnswer) { models.value(roomid)->sendMessageEvent(callAnswer, mtx::events::EventType::CallAnswer); } void TimelineViewManager::queueCallMessage(const QString &roomid, const mtx::events::msg::CallHangUp &callHangUp) { models.value(roomid)->sendMessageEvent(callHangUp, mtx::events::EventType::CallHangUp); }