diff --git a/CMakeLists.txt b/CMakeLists.txt index 54e49bf4c..b5516c36e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ if(ANDROID) else() find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Widgets) find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle KIO WindowSystem StatusNotifierItem) + find_package(KF6SyntaxHighlighting ${KF_MIN_VERSION} REQUIRED) set_package_properties(KF6QQC2DesktopStyle PROPERTIES TYPE RUNTIME ) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 40f714448..c2ef77323 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -532,7 +532,7 @@ if(ANDROID) ) ecm_add_android_apk(neochat-app ANDROID_DIR ${CMAKE_SOURCE_DIR}/android) else() - target_link_libraries(neochat PUBLIC Qt::Widgets KF6::KIOWidgets) + target_link_libraries(neochat PUBLIC Qt::Widgets KF6::KIOWidgets KF6::SyntaxHighlighting) install(FILES neochat.notifyrc DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR}) endif() diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h index b9a6722a7..aeed12947 100644 --- a/src/enums/messagecomponenttype.h +++ b/src/enums/messagecomponenttype.h @@ -40,7 +40,8 @@ public: Code, /**< A code section. */ Quote, /**< A quote section. */ File, /**< A message that is a file. */ - Itinerary, /**< A preview for a file that can integrate with KDE itinerary.. */ + Itinerary, /**< A preview for a file that can integrate with KDE itinerary. */ + Pdf, /**< A preview for a PDF file. */ Poll, /**< The initial event for a poll. */ Location, /**< A location event. */ LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */ diff --git a/src/filetype.cpp b/src/filetype.cpp index 18c6bbe68..440804c59 100644 --- a/src/filetype.cpp +++ b/src/filetype.cpp @@ -41,6 +41,12 @@ FileType::~FileType() noexcept { } +FileType &FileType::instance() +{ + static FileType _instance; + return _instance; +} + QMimeType FileType::mimeTypeForName(const QString &nameOrAlias) const { Q_D(const FileType); @@ -113,4 +119,10 @@ QStringList FileType::supportedAnimatedImageFormats() const return d->supportedAnimatedImageFormats; } +bool FileType::fileHasImage(const QUrl &file) const +{ + const auto mimeType = mimeTypeForFile(file.toString()); + return mimeType.isValid() && supportedImageFormats().contains(mimeType.preferredSuffix()); +} + #include "moc_filetype.cpp" diff --git a/src/filetype.h b/src/filetype.h index 25105c014..385f911a6 100644 --- a/src/filetype.h +++ b/src/filetype.h @@ -41,8 +41,13 @@ class FileType : public QObject Q_PROPERTY(QStringList supportedAnimatedImageFormats READ supportedAnimatedImageFormats CONSTANT FINAL) public: - explicit FileType(QObject *parent = nullptr); ~FileType(); + static FileType &instance(); + static FileType *create(QQmlEngine *engine, QJSEngine *) + { + engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership); + return &instance(); + } /** * @brief Returns a MIME type for nameOrAlias or an invalid one if none found. @@ -120,7 +125,11 @@ public: QStringList supportedImageFormats() const; QStringList supportedAnimatedImageFormats() const; + bool fileHasImage(const QUrl &file) const; + private: + explicit FileType(QObject *parent = nullptr); + const QScopedPointer d_ptr; Q_DECLARE_PRIVATE(FileType) Q_DISABLE_COPY(FileType) diff --git a/src/models/itinerarymodel.cpp b/src/models/itinerarymodel.cpp index 1a8870b94..af1153dd0 100644 --- a/src/models/itinerarymodel.cpp +++ b/src/models/itinerarymodel.cpp @@ -133,6 +133,11 @@ void ItineraryModel::loadData() beginResetModel(); m_data = QJsonDocument::fromJson(data).array(); endResetModel(); + + Q_EMIT loaded(); + }); + connect(process, &QProcess::errorOccurred, this, [this]() { + Q_EMIT loadErrorOccurred(); }); } diff --git a/src/models/itinerarymodel.h b/src/models/itinerarymodel.h index b5f1c5892..5b018c72e 100644 --- a/src/models/itinerarymodel.h +++ b/src/models/itinerarymodel.h @@ -44,6 +44,10 @@ public: Q_INVOKABLE void sendToItinerary(); +Q_SIGNALS: + void loaded(); + void loadErrorOccurred(); + private: QJsonArray m_data; QString m_path; diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index 55bc1adf2..f1a5d0cd5 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -3,15 +3,25 @@ #include "messagecontentmodel.h" +#include + #include #include #include +#include #include +#ifndef Q_OS_ANDROID +#include +#include +#endif + #include "chatbarcache.h" #include "enums/messagecomponenttype.h" #include "eventhandler.h" +#include "filetype.h" +#include "itinerarymodel.h" #include "linkpreviewer.h" #include "neochatroom.h" #include "texthandler.h" @@ -255,9 +265,38 @@ void MessageContentModel::updateComponents(bool isEditing) m_components.append(TextHandler().textComponents(body, EventHandler::messageBodyInputFormat(*event), m_room, event, event->isReplaced())); } else if (eventHandler.messageComponentType() == MessageComponentType::File) { m_components += MessageComponent{MessageComponentType::File, QString(), {}}; - updateItineraryModel(); - if (m_itineraryModel != nullptr) { - m_components += MessageComponent{MessageComponentType::Itinerary, QString(), {}}; + if (m_emptyItinerary) { + Quotient::FileTransferInfo fileTransferInfo; + if (auto event = eventCast(m_event)) { + if (event->hasFileContent()) { + fileTransferInfo = m_room->fileTransferInfo(event->id()); + } + } + if (auto event = eventCast(m_event)) { + fileTransferInfo = m_room->fileTransferInfo(event->id()); + } + +#ifndef Q_OS_ANDROID + KSyntaxHighlighting::Repository repository; + const auto definitionForFile = repository.definitionForFileName(fileTransferInfo.localPath.toString()); + if (definitionForFile.isValid() || QFileInfo(fileTransferInfo.localPath.path()).suffix() == QStringLiteral("txt")) { + QFile file(fileTransferInfo.localPath.path()); + file.open(QIODevice::ReadOnly); + m_components += MessageComponent{MessageComponentType::Code, + QString::fromStdString(file.readAll().toStdString()), + {{QStringLiteral("class"), definitionForFile.name()}}}; + } +#endif + + if (FileType::instance().fileHasImage(fileTransferInfo.localPath)) { + QImageReader reader(fileTransferInfo.localPath.path()); + m_components += MessageComponent{MessageComponentType::Pdf, QString(), {{QStringLiteral("size"), reader.size()}}}; + } + } else { + updateItineraryModel(); + if (m_itineraryModel != nullptr) { + m_components += MessageComponent{MessageComponentType::Itinerary, QString(), {}}; + } } } else { m_components += MessageComponent{eventHandler.messageComponentType(), QString(), {}}; @@ -290,6 +329,20 @@ void MessageContentModel::updateItineraryModel() } else if (!filePath.isEmpty()) { if (m_itineraryModel == nullptr) { m_itineraryModel = new ItineraryModel(this); + connect(m_itineraryModel, &ItineraryModel::loaded, this, [this]() { + if (m_itineraryModel->rowCount() == 0) { + m_itineraryModel->deleteLater(); + m_itineraryModel = nullptr; + m_emptyItinerary = true; + updateComponents(); + } + }); + connect(m_itineraryModel, &ItineraryModel::loadErrorOccurred, this, [this]() { + m_itineraryModel->deleteLater(); + m_itineraryModel = nullptr; + m_emptyItinerary = true; + updateComponents(); + }); } m_itineraryModel->setPath(filePath.toString()); } diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h index 156809d5a..0e7da0818 100644 --- a/src/models/messagecontentmodel.h +++ b/src/models/messagecontentmodel.h @@ -97,4 +97,5 @@ private: ItineraryModel *m_itineraryModel = nullptr; void updateItineraryModel(); + bool m_emptyItinerary = false; }; diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index f714e0275..c72215c9b 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -29,6 +29,7 @@ qt_add_qml_module(timeline LocationComponent.qml MessageEditComponent.qml MimeComponent.qml + PdfPreviewComponent.qml PollComponent.qml QuoteComponent.qml ReplyComponent.qml diff --git a/src/timeline/CodeComponent.qml b/src/timeline/CodeComponent.qml index 2554670b1..721d1da69 100644 --- a/src/timeline/CodeComponent.qml +++ b/src/timeline/CodeComponent.qml @@ -41,44 +41,25 @@ QQC2.Control { Layout.fillWidth: true Layout.fillHeight: true Layout.maximumWidth: root.maxContentWidth + Layout.maximumHeight: Kirigami.Units.gridUnit * 20 topPadding: 0 bottomPadding: 0 + leftPadding: 0 + rightPadding: 0 - contentItem: RowLayout { - spacing: Kirigami.Units.smallSpacing + contentItem: QQC2.ScrollView { + id: codeScrollView + contentWidth: root.maxContentWidth - ColumnLayout { - id: lineNumberColumn - spacing: 0 - Repeater { - id: repeater - model: LineModel { - id: lineModel - document: codeText.textDocument - } - delegate: QQC2.Label { - id: label - required property int index - required property int docLineHeight - Layout.fillWidth: true - Layout.preferredHeight: docLineHeight - horizontalAlignment: Text.AlignRight - text: index + 1 - color: Kirigami.Theme.disabledTextColor + // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff - font.family: "monospace" - } - } - } - Kirigami.Separator { - Layout.fillHeight: true - } - TextEdit { + QQC2.TextArea { id: codeText - Layout.fillWidth: true topPadding: Kirigami.Units.smallSpacing bottomPadding: Kirigami.Units.smallSpacing + leftPadding: lineNumberColumn.width + lineNumberColumn.anchors.leftMargin + Kirigami.Units.smallSpacing * 2 text: root.display readOnly: true @@ -100,11 +81,51 @@ QQC2.Control { textEdit: definitionName == "None" ? null : codeText definition: definitionName } + ColumnLayout { + id: lineNumberColumn + anchors { + top: codeText.top + topMargin: codeText.topPadding + left: codeText.left + leftMargin: Kirigami.Units.smallSpacing + } + spacing: 0 + Repeater { + id: repeater + model: LineModel { + id: lineModel + document: codeText.textDocument + } + delegate: QQC2.Label { + id: label + required property int index + required property int docLineHeight + Layout.fillWidth: true + Layout.preferredHeight: docLineHeight + horizontalAlignment: Text.AlignRight + text: index + 1 + color: Kirigami.Theme.disabledTextColor + + font.family: "monospace" + } + } + } TapHandler { acceptedButtons: Qt.LeftButton onLongPressed: root.showMessageMenu() } + + background: null + } + } + + Kirigami.Separator { + anchors { + top: root.top + bottom: root.bottom + left: root.left + leftMargin: lineNumberColumn.width + lineNumberColumn.anchors.leftMargin + Kirigami.Units.smallSpacing } } @@ -113,7 +134,7 @@ QQC2.Control { top: parent.top topMargin: Kirigami.Units.smallSpacing right: parent.right - rightMargin: Kirigami.Units.smallSpacing + rightMargin: (codeScrollView.QQC2.ScrollBar.vertical.visible ? codeScrollView.QQC2.ScrollBar.vertical.width : 0) + Kirigami.Units.smallSpacing } visible: root.hovered icon.name: "edit-copy" diff --git a/src/timeline/MessageComponentChooser.qml b/src/timeline/MessageComponentChooser.qml index 996523642..fa0dcdbf8 100644 --- a/src/timeline/MessageComponentChooser.qml +++ b/src/timeline/MessageComponentChooser.qml @@ -124,6 +124,13 @@ DelegateChooser { } } + DelegateChoice { + roleValue: MessageComponentType.Pdf + delegate: PdfPreviewComponent { + maxContentWidth: root.maxContentWidth + } + } + DelegateChoice { roleValue: MessageComponentType.Poll delegate: PollComponent { diff --git a/src/timeline/PdfPreviewComponent.qml b/src/timeline/PdfPreviewComponent.qml new file mode 100644 index 000000000..636a6c44c --- /dev/null +++ b/src/timeline/PdfPreviewComponent.qml @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2024 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Layouts + +import org.kde.neochat + +Rectangle { + id: root + + /** + * @brief FileTransferInfo for any downloading files. + */ + required property var fileTransferInfo + + /** + * @brief The attributes of the component. + */ + required property var componentAttributes + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.preferredWidth: mediaSizeHelper.currentSize.width + Layout.preferredHeight: mediaSizeHelper.currentSize.height + + color: "white" + + Image { + anchors.fill: root + source: root?.fileTransferInfo.localPath ?? "" + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.maxContentWidth + mediaWidth: root.componentAttributes.size.width + mediaHeight: root.componentAttributes.size.height + } + } +}