diff --git a/autotests/eventhandlertest.cpp b/autotests/eventhandlertest.cpp index 847e433cc..3c7d552cd 100644 --- a/autotests/eventhandlertest.cpp +++ b/autotests/eventhandlertest.cpp @@ -12,7 +12,6 @@ #include #include -#include "enums/delegatetype.h" #include "linkpreviewer.h" #include "models/reactionmodel.h" #include "neochatroom.h" @@ -37,9 +36,6 @@ private Q_SLOTS: void eventId(); void nullEventId(); - void delegateType_data(); - void delegateType(); - void nullDelegateType(); void author(); void nullAuthor(); void authorDisplayName(); @@ -67,8 +63,6 @@ private Q_SLOTS: void nullHasReply(); void replyId(); void nullReplyId(); - void replyDelegateType(); - void nullReplyDelegateType(); void replyAuthor(); void nullReplyAuthor(); void replyBody(); @@ -102,35 +96,6 @@ void EventHandlerTest::nullEventId() QCOMPARE(noEventHandler.getId(), QString()); } -void EventHandlerTest::delegateType_data() -{ - QTest::addColumn("eventNum"); - QTest::addColumn("delegateType"); - - QTest::newRow("message") << 0 << DelegateType::Message; - QTest::newRow("state") << 1 << DelegateType::State; - QTest::newRow("message 2") << 2 << DelegateType::Message; - QTest::newRow("reaction") << 3 << DelegateType::Other; - QTest::newRow("video") << 4 << DelegateType::Video; - QTest::newRow("location") << 7 << DelegateType::Location; -} - -void EventHandlerTest::delegateType() -{ - QFETCH(int, eventNum); - QFETCH(DelegateType::Type, delegateType); - - EventHandler eventHandler(room, room->messageEvents().at(eventNum).get()); - QCOMPARE(eventHandler.getDelegateType(), delegateType); -} - -void EventHandlerTest::nullDelegateType() -{ - EventHandler noEventHandler(room, nullptr); - QTest::ignoreMessage(QtWarningMsg, "getDelegateType called with m_event set to nullptr."); - QCOMPARE(noEventHandler.getDelegateType(), DelegateType::Other); -} - void EventHandlerTest::author() { auto event = room->messageEvents().at(0).get(); @@ -409,25 +374,6 @@ void EventHandlerTest::nullReplyId() QCOMPARE(noEventHandler.getReplyId(), QString()); } -void EventHandlerTest::replyDelegateType() -{ - EventHandler eventHandlerReply(room, room->messageEvents().at(5).get()); - QCOMPARE(eventHandlerReply.getReplyDelegateType(), DelegateType::Message); - - EventHandler eventHandlerNoReply(room, room->messageEvents().at(0).get()); - QCOMPARE(eventHandlerNoReply.getReplyDelegateType(), DelegateType::Other); -} - -void EventHandlerTest::nullReplyDelegateType() -{ - QTest::ignoreMessage(QtWarningMsg, "getReplyDelegateType called with m_room set to nullptr."); - QCOMPARE(emptyHandler.getReplyDelegateType(), DelegateType::Other); - - EventHandler noEventHandler(room, nullptr); - QTest::ignoreMessage(QtWarningMsg, "getReplyDelegateType called with m_event set to nullptr."); - QCOMPARE(noEventHandler.getReplyDelegateType(), DelegateType::Other); -} - void EventHandlerTest::replyAuthor() { auto replyEvent = room->messageEvents().at(0).get(); diff --git a/autotests/messageeventmodeltest.cpp b/autotests/messageeventmodeltest.cpp index 0c3b54872..f6b3e67c5 100644 --- a/autotests/messageeventmodeltest.cpp +++ b/autotests/messageeventmodeltest.cpp @@ -103,7 +103,6 @@ void MessageEventModelTest::simpleTimeline() QCOMPARE(model->data(model->index(1)), QStringLiteral("This is an example
text message
")); QCOMPARE(model->data(model->index(1), MessageEventModel::DelegateTypeRole), DelegateType::Message); - QCOMPARE(model->data(model->index(1), MessageEventModel::PlainText), QStringLiteral("This is an example\ntext message")); QCOMPARE(model->data(model->index(1), MessageEventModel::EventIdRole), QStringLiteral("$153456789:example.org")); QTest::ignoreMessage(QtWarningMsg, "Index QModelIndex(-1,-1,0x0,QObject(0x0)) is not valid (expected valid)"); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 70cd87f27..2979bf4ac 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -153,6 +153,9 @@ add_library(neochat STATIC events/locationbeaconevent.h events/serveraclevent.h events/widgetevent.h + enums/messagecomponenttype.h + models/messagecontentmodel.cpp + models/messagecontentmodel.h ) qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN @@ -198,21 +201,12 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/TimelineDelegate.qml qml/ReplyComponent.qml qml/StateDelegate.qml - qml/RichLabel.qml qml/MessageDelegate.qml qml/Bubble.qml qml/SectionDelegate.qml - qml/VideoDelegate.qml qml/ReactionDelegate.qml - qml/LinkPreviewDelegate.qml - qml/AudioDelegate.qml - qml/FileDelegate.qml - qml/ImageDelegate.qml - qml/EncryptedDelegate.qml qml/EventDelegate.qml - qml/TextDelegate.qml qml/ReadMarkerDelegate.qml - qml/PollDelegate.qml qml/MimeComponent.qml qml/StateComponent.qml qml/MessageEditComponent.qml @@ -276,14 +270,12 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/EmojiDelegate.qml qml/EmojiGrid.qml qml/RoomSearchPage.qml - qml/LocationDelegate.qml qml/LocationChooser.qml qml/TimelineView.qml qml/InvitationView.qml qml/AvatarTabButton.qml qml/SpaceDrawer.qml qml/OsmLocationPlugin.qml - qml/LiveLocationDelegate.qml qml/FullScreenMap.qml qml/LocationsPage.qml qml/LocationMapItem.qml @@ -310,6 +302,18 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/ServerComboBox.qml qml/UserSearchPage.qml qml/ManualUserDialog.qml + qml/MessageComponentChooser.qml + qml/TextComponent.qml + qml/ImageComponent.qml + qml/VideoComponent.qml + qml/AudioComponent.qml + qml/EncryptedComponent.qml + qml/FileComponent.qml + qml/LocationComponent.qml + qml/LiveLocationComponent.qml + qml/PollComponent.qml + qml/LinkPreviewComponent.qml + qml/LoadComponent.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/chatbarcache.cpp b/src/chatbarcache.cpp index 3a306499b..17f2a47a4 100644 --- a/src/chatbarcache.cpp +++ b/src/chatbarcache.cpp @@ -43,14 +43,14 @@ void ChatBarCache::setReplyId(const QString &replyId) if (m_relationType == Reply && m_relationId == replyId) { return; } - m_relationId = replyId; + const auto oldEventId = std::exchange(m_relationId, replyId); if (m_relationId.isEmpty()) { m_relationType = None; } else { m_relationType = Reply; } m_attachmentPath = QString(); - Q_EMIT relationIdChanged(); + Q_EMIT relationIdChanged(oldEventId, m_relationId); Q_EMIT attachmentPathChanged(); } @@ -72,14 +72,14 @@ void ChatBarCache::setEditId(const QString &editId) if (m_relationType == Edit && m_relationId == editId) { return; } - m_relationId = editId; + const auto oldEventId = std::exchange(m_relationId, editId); if (m_relationId.isEmpty()) { m_relationType = None; } else { m_relationType = Edit; } m_attachmentPath = QString(); - Q_EMIT relationIdChanged(); + Q_EMIT relationIdChanged(oldEventId, m_relationId); Q_EMIT attachmentPathChanged(); } @@ -153,9 +153,9 @@ void ChatBarCache::setAttachmentPath(const QString &attachmentPath) } m_attachmentPath = attachmentPath; m_relationType = None; - m_relationId = QString(); + const auto oldEventId = std::exchange(m_relationId, QString()); Q_EMIT attachmentPathChanged(); - Q_EMIT relationIdChanged(); + Q_EMIT relationIdChanged(oldEventId, m_relationId); } QList *ChatBarCache::mentions() diff --git a/src/chatbarcache.h b/src/chatbarcache.h index 433295e16..9e3d4bef2 100644 --- a/src/chatbarcache.h +++ b/src/chatbarcache.h @@ -186,7 +186,7 @@ public: Q_SIGNALS: void textChanged(); - void relationIdChanged(); + void relationIdChanged(const QString &oldEventId, const QString &newEventId); void threadIdChanged(); void attachmentPathChanged(); diff --git a/src/enums/delegatetype.h b/src/enums/delegatetype.h index f1095f5d3..4ddb202cc 100644 --- a/src/enums/delegatetype.h +++ b/src/enums/delegatetype.h @@ -6,6 +6,13 @@ #include #include +#include +#include +#include +#include + +#include "events/pollevent.h" + /** * @class DelegateType * @@ -26,23 +33,34 @@ public: * similar to the spec it is not the same. */ enum Type { - Emote, /**< A message that begins with /me. */ - Notice, /**< A notice event. */ - Image, /**< A message that is an image. */ - Audio, /**< A message that is an audio recording. */ - Video, /**< A message that is a video. */ - File, /**< A message that is a file. */ Message, /**< A text message. */ - Sticker, /**< A message that is a sticker. */ State, /**< A state event in the room. */ - Encrypted, /**< An encrypted message that cannot be decrypted. */ ReadMarker, /**< The local user read marker. */ - 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). */ Loading, /**< A delegate to tell the user more messages are being loaded. */ TimelineEnd, /**< A delegate to inform that all messages are loaded. */ Other, /**< Anything that cannot be classified as another type. */ }; Q_ENUM(Type); + + /** + * @brief Return the delegate type for the given event. + * + * @param event the event to return a type for. + * + * @sa Type + */ + static Type typeForEvent(const Quotient::RoomEvent &event) + { + if (event.is() || event.is() || event.is() + || event.is()) { + return Message; + } + if (event.isStateEvent()) { + if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { + return Message; + } + return State; + } + return Other; + } }; diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h new file mode 100644 index 000000000..a9e0702c1 --- /dev/null +++ b/src/enums/messagecomponenttype.h @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "events/pollevent.h" + +/** + * @class MessageComponentType + * + * This class is designed to define the MessageComponentType enumeration. + */ +class MessageComponentType : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + /** + * @brief The type of component that is needed for an event. + * + * @note While similar this is not the matrix event or message type. This is + * to tell a QML Bubble what component to use to visualise all or part of + * a room message. + */ + enum Type { + Text, /**< A text message. */ + Image, /**< A message that is an image. */ + Audio, /**< A message that is an audio recording. */ + Video, /**< A message that is a video. */ + File, /**< A message that is a 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). */ + Encrypted, /**< An encrypted message that cannot be decrypted. */ + Reply, /**< A component to show a replied-to message. */ + ReplyLoad, /**< A loading dialog for a reply. */ + LinkPreview, /**< A preview of a URL in the message. */ + LinkPreviewLoad, /**< A loading dialog for a link preview. */ + Edit, /**< A text edit for editing a message. */ + Other, /**< Anything that cannot be classified as another type. */ + }; + Q_ENUM(Type); + + /** + * @brief Return the delegate type for the given event. + * + * @param event the event to return a type for. + * + * @sa Type + */ + static Type typeForEvent(const Quotient::RoomEvent &event) + { + using namespace Quotient; + + if (const auto e = eventCast(&event)) { + switch (e->msgtype()) { + case MessageEventType::Emote: + return MessageComponentType::Text; + case MessageEventType::Notice: + return MessageComponentType::Text; + case MessageEventType::Image: + return MessageComponentType::Image; + case MessageEventType::Audio: + return MessageComponentType::Audio; + case MessageEventType::Video: + return MessageComponentType::Video; + case MessageEventType::Location: + return MessageComponentType::Location; + case MessageEventType::File: + return MessageComponentType::File; + default: + return MessageComponentType::Text; + } + } + if (is(event)) { + return MessageComponentType::Image; + } + if (event.isStateEvent()) { + if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { + return MessageComponentType::LiveLocation; + } + return MessageComponentType::Other; + } + if (is(event)) { + return MessageComponentType::Encrypted; + } + if (is(event)) { + const auto pollEvent = eventCast(&event); + if (pollEvent->isRedacted()) { + return MessageComponentType::Text; + } + return MessageComponentType::Poll; + } + + return MessageComponentType::Other; + } +}; diff --git a/src/eventhandler.cpp b/src/eventhandler.cpp index 307aa4d06..e3cce7957 100644 --- a/src/eventhandler.cpp +++ b/src/eventhandler.cpp @@ -14,18 +14,19 @@ #include #include #include +#include #include #include #include #include -#include "delegatetype.h" #include "eventhandler_logging.h" #include "events/locationbeaconevent.h" #include "events/pollevent.h" #include "events/serveraclevent.h" #include "events/widgetevent.h" #include "linkpreviewer.h" +#include "messagecomponenttype.h" #include "models/reactionmodel.h" #include "neochatconfig.h" #include "neochatroom.h" @@ -50,62 +51,14 @@ QString EventHandler::getId() const return !m_event->id().isEmpty() ? m_event->id() : m_event->transactionId(); } -DelegateType::Type EventHandler::getDelegateTypeForEvent(const Quotient::RoomEvent *event) const -{ - if (auto e = eventCast(event)) { - switch (e->msgtype()) { - case MessageEventType::Emote: - return DelegateType::Emote; - case MessageEventType::Notice: - return DelegateType::Notice; - case MessageEventType::Image: - return DelegateType::Image; - case MessageEventType::Audio: - return DelegateType::Audio; - case MessageEventType::Video: - return DelegateType::Video; - case MessageEventType::Location: - return DelegateType::Location; - default: - break; - } - if (e->hasFileContent()) { - return DelegateType::File; - } - - return DelegateType::Message; - } - if (is(*event)) { - return DelegateType::Sticker; - } - if (event->isStateEvent()) { - if (event->matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { - return DelegateType::LiveLocation; - } - return DelegateType::State; - } - if (is(*event)) { - return DelegateType::Encrypted; - } - if (is(*event)) { - const auto pollEvent = eventCast(event); - if (pollEvent->isRedacted()) { - return DelegateType::Message; - } - return DelegateType::Poll; - } - - return DelegateType::Other; -} - -DelegateType::Type EventHandler::getDelegateType() const +MessageComponentType::Type EventHandler::messageComponentType() const { if (m_event == nullptr) { - qCWarning(EventHandling) << "getDelegateType called with m_event set to nullptr."; - return DelegateType::Other; + qCWarning(EventHandling) << "messageComponentType called with m_event set to nullptr."; + return MessageComponentType::Other; } - return getDelegateTypeForEvent(m_event); + return MessageComponentType::typeForEvent(*m_event); } QVariantMap EventHandler::getAuthor(bool isPending) const @@ -776,22 +729,22 @@ QString EventHandler::getReplyId() const return m_event->contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString(); } -DelegateType::Type EventHandler::getReplyDelegateType() const +MessageComponentType::Type EventHandler::replyMessageComponentType() const { if (m_room == nullptr) { - qCWarning(EventHandling) << "getReplyDelegateType called with m_room set to nullptr."; - return DelegateType::Other; + qCWarning(EventHandling) << "replyMessageComponentType called with m_room set to nullptr."; + return MessageComponentType::Other; } if (m_event == nullptr) { - qCWarning(EventHandling) << "getReplyDelegateType called with m_event set to nullptr."; - return DelegateType::Other; + qCWarning(EventHandling) << "replyMessageComponentType called with m_event set to nullptr."; + return MessageComponentType::Other; } auto replyEvent = m_room->getReplyForEvent(*m_event); if (replyEvent == nullptr) { - return DelegateType::Other; + return MessageComponentType::Other; } - return getDelegateTypeForEvent(replyEvent); + return MessageComponentType::typeForEvent(*replyEvent); } QVariantMap EventHandler::getReplyAuthor() const diff --git a/src/eventhandler.h b/src/eventhandler.h index 03c588488..9fc72000f 100644 --- a/src/eventhandler.h +++ b/src/eventhandler.h @@ -11,7 +11,7 @@ #include #include -#include "enums/delegatetype.h" +#include "enums/messagecomponenttype.h" class LinkPreviewer; class NeoChatRoom; @@ -44,13 +44,9 @@ public: QString getId() const; /** - * @brief Return the DelegateType of the event. - * - * @note While similar this is not the matrix event or message type. This is - * to tell a QML ListView what delegate to show for each event. So while - * similar to the spec it is not the same. + * @brief The MessageComponentType to use to visualise the main event content. */ - DelegateType::Type getDelegateType() const; + MessageComponentType::Type messageComponentType() const; /** * @brief Get the author of the event in context of the room. @@ -224,13 +220,9 @@ public: QString getReplyId() const; /** - * @brief Return the DelegateType of the event replied to. - * - * @note While similar this is not the matrix event or message type. This is - * to tell a QML ListView what delegate to show for each event. So while - * similar to the spec it is not the same. + * @brief The MessageComponentType to use to visualise the reply content. */ - DelegateType::Type getReplyDelegateType() const; + MessageComponentType::Type replyMessageComponentType() const; /** * @brief Get the author of the event replied to in context of the room. @@ -386,8 +378,6 @@ private: KFormat m_format; - DelegateType::Type getDelegateTypeForEvent(const Quotient::RoomEvent *event) const; - QString getBody(const Quotient::RoomEvent *event, Qt::TextFormat format, bool stripNewlines) const; QString getMessageBody(const Quotient::RoomMessageEvent &event, Qt::TextFormat format, bool stripNewlines) const; diff --git a/src/linkpreviewer.cpp b/src/linkpreviewer.cpp index c82560089..78e75b935 100644 --- a/src/linkpreviewer.cpp +++ b/src/linkpreviewer.cpp @@ -13,8 +13,8 @@ using namespace Quotient; -LinkPreviewer::LinkPreviewer(const NeoChatRoom *room, const Quotient::RoomMessageEvent *event) - : QObject(nullptr) +LinkPreviewer::LinkPreviewer(const NeoChatRoom *room, const Quotient::RoomMessageEvent *event, QObject *parent) + : QObject(parent) , m_currentRoom(room) , m_event(event) , m_loaded(false) diff --git a/src/linkpreviewer.h b/src/linkpreviewer.h index 5e85f422f..18554f540 100644 --- a/src/linkpreviewer.h +++ b/src/linkpreviewer.h @@ -60,7 +60,7 @@ class LinkPreviewer : public QObject Q_PROPERTY(bool empty READ empty NOTIFY emptyChanged) public: - explicit LinkPreviewer(const NeoChatRoom *room = nullptr, const Quotient::RoomMessageEvent *event = nullptr); + explicit LinkPreviewer(const NeoChatRoom *room = nullptr, const Quotient::RoomMessageEvent *event = nullptr, QObject *parent = nullptr); [[nodiscard]] QUrl url() const; [[nodiscard]] bool loaded() const; diff --git a/src/models/mediamessagefiltermodel.cpp b/src/models/mediamessagefiltermodel.cpp index bf7cef982..64025978b 100644 --- a/src/models/mediamessagefiltermodel.cpp +++ b/src/models/mediamessagefiltermodel.cpp @@ -3,9 +3,9 @@ #include "mediamessagefiltermodel.h" +#include #include -#include "enums/delegatetype.h" #include "messageeventmodel.h" #include "messagefiltermodel.h" @@ -20,8 +20,8 @@ bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex { const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); - if (index.data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image - || index.data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Video) { + if (index.data(MessageEventModel::MediaInfoRole).toMap()[QLatin1String("mimeType")].toString().contains(QLatin1String("image")) + || index.data(MessageEventModel::MediaInfoRole).toMap()[QLatin1String("mimeType")].toString().contains(QLatin1String("video"))) { return true; } return false; @@ -30,9 +30,9 @@ bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const { if (role == SourceRole) { - if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image) { + if (mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QLatin1String("mimeType")].toString().contains(QLatin1String("image"))) { return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QStringLiteral("source")].toUrl(); - } else if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Video) { + } else if (mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QLatin1String("mimeType")].toString().contains(QLatin1String("video"))) { auto progressInfo = mapToSource(index).data(MessageEventModel::ProgressInfoRole).value(); if (progressInfo.completed()) { @@ -48,7 +48,7 @@ QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QStringLiteral("tempInfo")].toMap()[QStringLiteral("source")].toUrl(); } if (role == TypeRole) { - if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image) { + if (mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QLatin1String("mimeType")].toString().contains(QLatin1String("image"))) { return MediaType::Image; } else { return MediaType::Video; diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp new file mode 100644 index 000000000..1b8cbcbb2 --- /dev/null +++ b/src/models/messagecontentmodel.cpp @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "messagecontentmodel.h" + +#include +#include + +#include + +#include "chatbarcache.h" +#include "enums/messagecomponenttype.h" +#include "eventhandler.h" +#include "linkpreviewer.h" +#include "neochatroom.h" + +MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room) + : QAbstractListModel(nullptr) + , m_room(room) + , m_event(event) +{ + if (m_room != nullptr) { + connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) { + if (m_room != nullptr && m_event != nullptr) { + if (m_event->id() == serverEvent->id()) { + beginResetModel(); + m_event = serverEvent; + endResetModel(); + } + } + }); + connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) { + if (m_room != nullptr && m_event != nullptr) { + if (m_event->id() == newEvent->id()) { + beginResetModel(); + m_event = newEvent; + endResetModel(); + } + } + }); + connect(m_room, &NeoChatRoom::replyLoaded, this, [this](const QString &eventId, const QString &replyId) { + Q_UNUSED(eventId) + if (m_event != nullptr && m_event != nullptr) { + const auto eventHandler = EventHandler(m_room, m_event); + if (replyId == eventHandler.getReplyId()) { + // HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate. + beginResetModel(); + m_components[0] = MessageComponentType::Reply; + endResetModel(); + } + } + }); + connect(m_room, &NeoChatRoom::newFileTransfer, this, [this](const QString &eventId) { + if (m_event != nullptr && m_event != nullptr && eventId == m_event->id()) { + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole}); + } + }); + connect(m_room, &NeoChatRoom::fileTransferProgress, this, [this](const QString &eventId) { + if (m_event != nullptr && m_event != nullptr && eventId == m_event->id()) { + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole}); + } + }); + connect(m_room, &NeoChatRoom::fileTransferCompleted, this, [this](const QString &eventId) { + if (m_event != nullptr && m_event != nullptr && eventId == m_event->id()) { + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole}); + } + }); + connect(m_room, &NeoChatRoom::fileTransferFailed, this, [this](const QString &eventId) { + if (m_event != nullptr && m_event != nullptr && eventId == m_event->id()) { + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole}); + } + }); + connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) { + if (m_event != nullptr && (oldEventId == m_event->id() || newEventId == m_event->id())) { + // HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate. + beginResetModel(); + endResetModel(); + } + }); + } + + if (const auto event = eventCast(m_event)) { + if (LinkPreviewer::hasPreviewableLinks(event)) { + m_linkPreviewer = new LinkPreviewer(m_room, event, this); + + connect(m_linkPreviewer, &LinkPreviewer::loadedChanged, [this]() { + if (m_linkPreviewer->loaded()) { + // HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate. + beginResetModel(); + m_components[m_components.size() - 1] = MessageComponentType::LinkPreview; + endResetModel(); + } + }); + } + } + + updateComponents(); +} + +static LinkPreviewer *emptyLinkPreview = new LinkPreviewer; + +QVariant MessageContentModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + if (index.row() >= rowCount()) { + qDebug() << "MessageContentModel, something's wrong: index.row() >= rowCount()"; + return {}; + } + + EventHandler eventHandler(m_room, m_event); + + if (role == DisplayRole) { + if (m_event->isRedacted()) { + auto reason = m_event->redactedBecause()->reason(); + return (reason.isEmpty()) ? i18n("[This message was deleted]") + : i18n("[This message was deleted: %1]", m_event->redactedBecause()->reason()); + } + return eventHandler.getRichBody(); + } + if (role == ComponentTypeRole) { + const auto component = m_components[index.row()]; + if (component == MessageComponentType::Text && !m_event->id().isEmpty() && m_room->editCache()->editId() == m_event->id()) { + return MessageComponentType::Edit; + } + return component; + } + if (role == EventIdRole) { + return eventHandler.getId(); + } + if (role == AuthorRole) { + return eventHandler.getAuthor(false); + } + if (role == MediaInfoRole) { + return eventHandler.getMediaInfo(); + } + if (role == FileTransferInfoRole) { + if (auto event = eventCast(m_event)) { + if (event->hasFileContent()) { + return QVariant::fromValue(m_room->fileTransferInfo(event->id())); + } + } + if (auto event = eventCast(m_event)) { + return QVariant::fromValue(m_room->fileTransferInfo(event->id())); + } + } + if (role == LatitudeRole) { + return eventHandler.getLatitude(); + } + if (role == LongitudeRole) { + return eventHandler.getLongitude(); + } + if (role == AssetRole) { + return eventHandler.getLocationAssetType(); + } + if (role == PollHandlerRole) { + return QVariant::fromValue(m_room->poll(m_event->id())); + } + if (role == IsReplyRole) { + return eventHandler.hasReply(); + } + if (role == ReplyComponentType) { + return eventHandler.replyMessageComponentType(); + } + if (role == ReplyEventIdRole) { + return eventHandler.getReplyId(); + } + if (role == ReplyAuthorRole) { + return eventHandler.getReplyAuthor(); + } + if (role == ReplyDisplayRole) { + return eventHandler.getReplyRichBody(); + } + if (role == ReplyMediaInfoRole) { + return eventHandler.getReplyMediaInfo(); + } + if (role == LinkPreviewerRole) { + if (m_linkPreviewer != nullptr) { + return QVariant::fromValue(m_linkPreviewer); + } else { + return QVariant::fromValue(emptyLinkPreview); + } + } + + return {}; +} + +int MessageContentModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_components.size(); +} + +QHash MessageContentModel::roleNames() const +{ + QHash roles = QAbstractItemModel::roleNames(); + roles[DisplayRole] = "display"; + roles[ComponentTypeRole] = "componentType"; + roles[EventIdRole] = "eventId"; + roles[AuthorRole] = "author"; + roles[MediaInfoRole] = "mediaInfo"; + roles[FileTransferInfoRole] = "fileTransferInfo"; + roles[LatitudeRole] = "latitude"; + roles[LongitudeRole] = "longitude"; + roles[AssetRole] = "asset"; + roles[PollHandlerRole] = "pollHandler"; + roles[IsReplyRole] = "isReply"; + roles[ReplyComponentType] = "replyComponentType"; + roles[ReplyEventIdRole] = "replyEventId"; + roles[ReplyAuthorRole] = "replyAuthor"; + roles[ReplyDisplayRole] = "replyDisplay"; + roles[ReplyMediaInfoRole] = "replyMediaInfo"; + roles[LinkPreviewerRole] = "linkPreviewer"; + return roles; +} + +void MessageContentModel::updateComponents() +{ + beginResetModel(); + m_components.clear(); + + EventHandler eventHandler(m_room, m_event); + if (eventHandler.hasReply()) { + if (m_room->findInTimeline(eventHandler.getReplyId()) == m_room->historyEdge()) { + m_components += MessageComponentType::ReplyLoad; + m_room->loadReply(m_event->id(), eventHandler.getReplyId()); + } else { + m_components += MessageComponentType::Reply; + } + } + + m_components += eventHandler.messageComponentType(); + + if (m_linkPreviewer != nullptr) { + if (m_linkPreviewer->loaded()) { + m_components += MessageComponentType::LinkPreview; + } else { + m_components += MessageComponentType::LinkPreviewLoad; + } + } + + endResetModel(); +} diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h new file mode 100644 index 000000000..7539824cc --- /dev/null +++ b/src/models/messagecontentmodel.h @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include "eventhandler.h" +#include "linkpreviewer.h" +#include "messagecomponenttype.h" +#include "neochatroom.h" + +/** + * @class MessageContentModel + * + * A model to visualise the components of a single RoomMessageEvent. + */ +class MessageContentModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + /** + * @brief Defines the model roles. + */ + enum Roles { + DisplayRole = Qt::DisplayRole, /**< The display text for the message. */ + ComponentTypeRole, /**< The type of component to visualise the message. */ + EventIdRole, /**< The matrix event ID of the event. */ + AuthorRole, /**< The author of the event. */ + MediaInfoRole, /**< The media info for the event. */ + FileTransferInfoRole, /**< FileTransferInfo for any downloading files. */ + LatitudeRole, /**< Latitude for a location event. */ + LongitudeRole, /**< Longitude for a location event. */ + AssetRole, /**< Type of location event, e.g. self pin of the user location. */ + PollHandlerRole, /**< The PollHandler for the event, if any. */ + + IsReplyRole, /**< Is the message a reply to another event. */ + ReplyComponentType, /**< The type of component to visualise the reply message. */ + ReplyEventIdRole, /**< The matrix ID of the message that was replied to. */ + ReplyAuthorRole, /**< The author of the event that was replied to. */ + ReplyDisplayRole, /**< The body of the message that was replied to. */ + ReplyMediaInfoRole, /**< The media info of the message that was replied to. */ + + LinkPreviewerRole, /**< The link preview details. */ + }; + Q_ENUM(Roles) + + explicit MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room); + + /** + * @brief Get the given role value at the given index. + * + * @sa QAbstractItemModel::data + */ + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Number of rows in the model. + * + * @sa QAbstractItemModel::rowCount + */ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + +private: + NeoChatRoom *m_room = nullptr; + const Quotient::RoomEvent *m_event = nullptr; + + QVector m_components; + void updateComponents(); + + LinkPreviewer *m_linkPreviewer = nullptr; +}; diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 82dbac4af..3b718c71d 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-3.0-only #include "messageeventmodel.h" -#include "linkpreviewer.h" #include "messageeventmodel_logging.h" #include "neochatconfig.h" @@ -10,6 +9,7 @@ #include #include #include +#include #include #include @@ -22,6 +22,8 @@ #include "enums/delegatetype.h" #include "eventhandler.h" #include "events/pollevent.h" +#include "linkpreviewer.h" +#include "messagecontentmodel.h" #include "models/reactionmodel.h" #include "texthandler.h" @@ -31,7 +33,6 @@ QHash MessageEventModel::roleNames() const { QHash roles = QAbstractItemModel::roleNames(); roles[DelegateTypeRole] = "delegateType"; - roles[PlainText] = "plainText"; roles[EventIdRole] = "eventId"; roles[TimeRole] = "time"; roles[TimeStringRole] = "timeString"; @@ -40,15 +41,6 @@ QHash MessageEventModel::roleNames() const roles[HighlightRole] = "isHighlighted"; roles[SpecialMarksRole] = "marks"; roles[ProgressInfoRole] = "progressInfo"; - roles[ShowLinkPreviewRole] = "showLinkPreview"; - roles[LinkPreviewRole] = "linkPreview"; - roles[MediaInfoRole] = "mediaInfo"; - roles[IsReplyRole] = "isReply"; - roles[ReplyAuthor] = "replyAuthor"; - roles[ReplyIdRole] = "replyId"; - roles[ReplyDelegateTypeRole] = "replyDelegateType"; - roles[ReplyDisplayRole] = "replyDisplay"; - roles[ReplyMediaInfoRole] = "replyMediaInfo"; roles[IsThreadedRole] = "isThreaded"; roles[ThreadRootRole] = "threadRoot"; roles[ShowAuthorRole] = "showAuthor"; @@ -64,10 +56,8 @@ QHash MessageEventModel::roleNames() const roles[IsRedactedRole] = "isRedacted"; roles[GenericDisplayRole] = "genericDisplay"; roles[IsPendingRole] = "isPending"; - roles[LatitudeRole] = "latitude"; - roles[LongitudeRole] = "longitude"; - roles[AssetRole] = "asset"; - roles[PollHandlerRole] = "pollHandler"; + roles[ContentModelRole] = "contentModel"; + roles[MediaInfoRole] = "mediaInfo"; return roles; } @@ -96,7 +86,6 @@ void MessageEventModel::setRoom(NeoChatRoom *room) beginResetModel(); if (m_currentRoom) { m_currentRoom->disconnect(this); - m_linkPreviewers.clear(); m_reactionModels.clear(); } @@ -119,14 +108,6 @@ void MessageEventModel::setRoom(NeoChatRoom *room) room->getPreviousContent(50); } lastReadEventId = room->lastFullyReadEventId(); - connect(m_currentRoom, &NeoChatRoom::replyLoaded, this, [this](const auto &eventId, const auto &replyId) { - Q_UNUSED(replyId); - auto row = eventIdToRow(eventId); - if (row == -1) { - return; - } - Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyDelegateTypeRole, ReplyDisplayRole, ReplyMediaInfoRole, ReplyAuthor}); - }); connect(m_currentRoom, &Room::aboutToAddNewMessages, this, [this](RoomEventsRange events) { for (auto &&event : events) { @@ -238,7 +219,6 @@ void MessageEventModel::setRoom(NeoChatRoom *room) moveReadMarker(toEventId); }); connect(m_currentRoom, &Room::replacedEvent, this, [this](const RoomEvent *newEvent) { - refreshLastUserEvents(refreshEvent(newEvent->id()) - timelineBaseIndex()); const RoomMessageEvent *message = eventCast(newEvent); if (message != nullptr) { createEventObjects(message); @@ -265,10 +245,6 @@ void MessageEventModel::setRoom(NeoChatRoom *room) refreshEventRoles(event->id(), {ReadMarkersRole, ReadMarkersStringRole, ExcessReadMarkersRole}); } }); - connect(m_currentRoom, &Room::newFileTransfer, this, &MessageEventModel::refreshEvent); - connect(m_currentRoom, &Room::fileTransferProgress, this, &MessageEventModel::refreshEvent); - connect(m_currentRoom, &Room::fileTransferCompleted, this, &MessageEventModel::refreshEvent); - connect(m_currentRoom, &Room::fileTransferFailed, this, &MessageEventModel::refreshEvent); connect(m_currentRoom->connection(), &Connection::ignoredUsersListChanged, this, [this] { beginResetModel(); endResetModel(); @@ -439,8 +415,6 @@ void MessageEventModel::fetchMore(const QModelIndex &parent) } } -static LinkPreviewer *emptyLinkPreview = new LinkPreviewer; - QVariant MessageEventModel::data(const QModelIndex &idx, int role) const { if (!checkIndex(idx, QAbstractItemModel::CheckIndexOption::IndexIsValid)) { @@ -492,16 +466,24 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return eventHandler.getRichBody(); } + if (role == ContentModelRole) { + if (!evt.isStateEvent()) { + return QVariant::fromValue(new MessageContentModel(&evt, m_currentRoom)); + } + if (evt.isStateEvent()) { + if (evt.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { + return QVariant::fromValue(new MessageContentModel(&evt, m_currentRoom)); + } + } + return {}; + } + if (role == GenericDisplayRole) { return eventHandler.getGenericBody(); } - if (role == PlainText) { - return eventHandler.getPlainBody(); - } - if (role == DelegateTypeRole) { - return eventHandler.getDelegateType(); + return DelegateType::typeForEvent(evt); } if (role == AuthorRole) { @@ -559,46 +541,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return eventHandler.getTimeString(true, QLocale::ShortFormat, isPending, lastUpdated); } - if (role == ShowLinkPreviewRole) { - return m_linkPreviewers.contains(evt.id()); - } - - if (role == LinkPreviewRole) { - if (m_linkPreviewers.contains(evt.id())) { - return QVariant::fromValue(m_linkPreviewers[evt.id()].data()); - } else { - return QVariant::fromValue(emptyLinkPreview); - } - } - - if (role == MediaInfoRole) { - return eventHandler.getMediaInfo(); - } - - if (role == IsReplyRole) { - return eventHandler.hasReply(); - } - - if (role == ReplyIdRole) { - return eventHandler.getReplyId(); - } - - if (role == ReplyDelegateTypeRole) { - return eventHandler.getReplyDelegateType(); - } - - if (role == ReplyAuthor) { - return eventHandler.getReplyAuthor(); - } - - if (role == ReplyDisplayRole) { - return eventHandler.getReplyRichBody(); - } - - if (role == ReplyMediaInfoRole) { - return eventHandler.getReplyMediaInfo(); - } - if (role == IsThreadedRole) { return eventHandler.isThreaded(); } @@ -642,18 +584,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return false; } - if (role == LatitudeRole) { - return eventHandler.getLatitude(); - } - - if (role == LongitudeRole) { - return eventHandler.getLongitude(); - } - - if (role == AssetRole) { - return eventHandler.getLocationAssetType(); - } - if (role == ReadMarkersRole) { return eventHandler.getReadMarkers(); } @@ -703,8 +633,8 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return row < static_cast(m_currentRoom->pendingEvents().size()); } - if (role == PollHandlerRole) { - return QVariant::fromValue(m_currentRoom->poll(evt.id())); + if (role == MediaInfoRole) { + return eventHandler.getMediaInfo(); } return {}; @@ -724,16 +654,6 @@ void MessageEventModel::createEventObjects(const Quotient::RoomMessageEvent *eve { auto eventId = event->id(); - if (m_linkPreviewers.contains(eventId)) { - if (!LinkPreviewer::hasPreviewableLinks(event)) { - m_linkPreviewers.remove(eventId); - } - } else { - if (LinkPreviewer::hasPreviewableLinks(event)) { - m_linkPreviewers[eventId] = QSharedPointer(new LinkPreviewer(m_currentRoom, event)); - } - } - // ReactionModel handles updates to add and remove reactions, we only need to // handle adding and removing whole models here. if (m_reactionModels.contains(eventId)) { @@ -761,7 +681,7 @@ void MessageEventModel::createEventObjects(const Quotient::RoomMessageEvent *eve bool MessageEventModel::event(QEvent *event) { if (event->type() == QEvent::ApplicationPaletteChange) { - Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole, ReplyAuthor, ReadMarkersRole}); + Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole, ReadMarkersRole}); } return QObject::event(event); } diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 2b0c72e65..26d133b2a 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -40,7 +40,6 @@ public: */ enum EventRoles { DelegateTypeRole = Qt::UserRole + 1, /**< The delegate type of the message. */ - PlainText, /**< Plain text representation of the message. */ EventIdRole, /**< The matrix event ID of the event. */ TimeRole, /**< The timestamp for when the event was sent (as a QDateTime). */ TimeStringRole, /**< The timestamp for when the event was sent as a string (in QLocale::ShortFormat). */ @@ -50,18 +49,9 @@ public: SpecialMarksRole, /**< Whether the event is hidden or not. */ ProgressInfoRole, /**< Progress info when downloading files. */ GenericDisplayRole, /**< A generic string based upon the message type. */ - - ShowLinkPreviewRole, /**< Whether a link preview should be shown. */ - LinkPreviewRole, /**< The link preview details. */ - MediaInfoRole, /**< The media info for the event. */ - IsReplyRole, /**< Is the message a reply to another event. */ - ReplyAuthor, /**< The author of the event that was replied to. */ - ReplyIdRole, /**< The matrix ID of the message that was replied to. */ - ReplyDelegateTypeRole, /**< The delegate type of the message that was replied to. */ - ReplyDisplayRole, /**< The body of the message that was replied to. */ - ReplyMediaInfoRole, /**< The media info of the message that was replied to. */ + ContentModelRole, /**< The MessageContentModel for the event. */ IsThreadedRole, ThreadRootRole, @@ -80,10 +70,6 @@ public: AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */ IsRedactedRole, /**< Whether an event has been deleted. */ IsPendingRole, /**< Whether an event is waiting to be accepted by the server. */ - LatitudeRole, /**< Latitude for a location event. */ - LongitudeRole, /**< Longitude for a location event. */ - AssetRole, /**< Type of location event, e.g. self pin of the user location. */ - PollHandlerRole, /**< The PollHandler for the event, if any. */ LastRole, // Keep this last }; Q_ENUM(EventRoles) @@ -135,7 +121,6 @@ private: bool movingEvent = false; KFormat m_format; - QMap> m_linkPreviewers; QMap> m_reactionModels; [[nodiscard]] int timelineBaseIndex() const; diff --git a/src/models/searchmodel.cpp b/src/models/searchmodel.cpp index 410bb19c6..50a685b2d 100644 --- a/src/models/searchmodel.cpp +++ b/src/models/searchmodel.cpp @@ -3,8 +3,9 @@ #include "searchmodel.h" +#include "enums/delegatetype.h" #include "eventhandler.h" -#include "messageeventmodel.h" +#include "models/messagecontentmodel.h" #include "neochatroom.h" #include @@ -82,8 +83,6 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const EventHandler eventHandler(m_room, &event); switch (role) { - case DisplayRole: - return eventHandler.getRichBody(); case ShowAuthorRole: return true; case AuthorRole: @@ -103,22 +102,8 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const return false; case ShowReadMarkersRole: return false; - case IsReplyRole: - return eventHandler.hasReply(); - case ReplyIdRole: - return eventHandler.hasReply(); - case ReplyAuthorRole: - return eventHandler.getReplyAuthor(); - case ReplyDelegateTypeRole: - return eventHandler.getReplyDelegateType(); - case ReplyDisplayRole: - return eventHandler.getReplyRichBody(); - case ReplyMediaInfoRole: - return eventHandler.getReplyMediaInfo(); case IsPendingRole: return false; - case ShowLinkPreviewRole: - return false; case HighlightRole: return eventHandler.isHighlighted(); case EventIdRole: @@ -127,6 +112,17 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const return eventHandler.isThreaded(); case ThreadRootRole: return eventHandler.threadRoot(); + case ContentModelRole: { + if (!event.isStateEvent()) { + return QVariant::fromValue(new MessageContentModel(&event, m_room)); + } + if (event.isStateEvent()) { + if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { + return QVariant::fromValue(new MessageContentModel(&event, m_room)); + } + } + return {}; + } } return DelegateType::Message; } @@ -144,7 +140,6 @@ QHash SearchModel::roleNames() const { return { {DelegateTypeRole, "delegateType"}, - {DisplayRole, "display"}, {AuthorRole, "author"}, {ShowSectionRole, "showSection"}, {SectionRole, "section"}, @@ -155,25 +150,15 @@ QHash SearchModel::roleNames() const {ExcessReadMarkersRole, "excessReadMarkers"}, {HighlightRole, "isHighlighted"}, {ReadMarkersString, "readMarkersString"}, - {PlainTextRole, "plainText"}, {VerifiedRole, "verified"}, - {ProgressInfoRole, "progressInfo"}, {ShowReactionsRole, "showReactions"}, - {IsReplyRole, "isReply"}, - {ReplyAuthorRole, "replyAuthor"}, - {ReplyIdRole, "replyId"}, - {ReplyDelegateTypeRole, "replyDelegateType"}, - {ReplyDisplayRole, "replyDisplay"}, - {ReplyMediaInfoRole, "replyMediaInfo"}, {ReactionRole, "reaction"}, {ReadMarkersRole, "readMarkers"}, {IsPendingRole, "isPending"}, {ShowReadMarkersRole, "showReadMarkers"}, - {MimeTypeRole, "mimeType"}, - {ShowLinkPreviewRole, "showLinkPreview"}, - {LinkPreviewRole, "linkPreview"}, {IsThreadedRole, "isThreaded"}, {ThreadRootRole, "threadRoot"}, + {ContentModelRole, "contentModel"}, }; } @@ -189,19 +174,6 @@ void SearchModel::setRoom(NeoChatRoom *room) } m_room = room; Q_EMIT roomChanged(); - - connect(m_room, &NeoChatRoom::replyLoaded, this, [this](const auto &eventId, const auto &replyId) { - Q_UNUSED(replyId); - const auto &results = m_result->results; - auto it = std::find_if(results.begin(), results.end(), [eventId](const auto &event) { - return event.result->id() == eventId; - }); - if (it == results.end()) { - return; - } - auto row = it - results.begin(); - Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyDelegateTypeRole, ReplyDisplayRole, ReplyMediaInfoRole, ReplyAuthorRole}); - }); } bool SearchModel::searching() const diff --git a/src/models/searchmodel.h b/src/models/searchmodel.h index c140dfc1f..fd4bb7580 100644 --- a/src/models/searchmodel.h +++ b/src/models/searchmodel.h @@ -51,8 +51,7 @@ public: * since the same delegates are used. */ enum Roles { - DisplayRole = Qt::DisplayRole, - DelegateTypeRole, + DelegateTypeRole = Qt::DisplayRole + 1, ShowAuthorRole, AuthorRole, ShowSectionRole, @@ -63,25 +62,15 @@ public: ExcessReadMarkersRole, HighlightRole, ReadMarkersString, - PlainTextRole, VerifiedRole, - ProgressInfoRole, ShowReactionsRole, - IsReplyRole, - ReplyAuthorRole, - ReplyIdRole, - ReplyDelegateTypeRole, - ReplyDisplayRole, - ReplyMediaInfoRole, ReactionRole, ReadMarkersRole, IsPendingRole, ShowReadMarkersRole, - MimeTypeRole, - ShowLinkPreviewRole, - LinkPreviewRole, IsThreadedRole, ThreadRootRole, + ContentModelRole, }; Q_ENUM(Roles) explicit SearchModel(QObject *parent = nullptr); diff --git a/src/qml/AudioComponent.qml b/src/qml/AudioComponent.qml new file mode 100644 index 000000000..8d9abed9c --- /dev/null +++ b/src/qml/AudioComponent.qml @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtMultimedia + +import org.kde.coreaddons +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +/** + * @brief A component to show audio from a message. + */ +ColumnLayout { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be image/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be image-xxx). + * - size - The file size in bytes. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + + /** + * @brief FileTransferInfo for any downloading files. + */ + required property var fileTransferInfo + + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed + onDownloadedChanged: if (downloaded) { + audio.play() + } + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + MediaPlayer { + id: audio + onErrorOccurred: (error, errorString) => console.warn("Audio playback error:" + error + errorString) + audioOutput: AudioOutput {} + } + + states: [ + State { + name: "notDownloaded" + when: !root.fileTransferInfo.completed && !root.fileTransferInfo.active + + PropertyChanges { + target: playButton + icon.name: "media-playback-start" + onClicked: root.room.downloadFile(root.eventId) + } + }, + State { + name: "downloading" + when: root.fileTransferInfo.active && !root.fileTransferInfo.completed + PropertyChanges { + target: downloadBar + visible: true + } + PropertyChanges { + target: playButton + icon.name: "media-playback-stop" + onClicked: { + root.room.cancelFileTransfer(root.eventId) + } + } + }, + State { + name: "paused" + when: root.fileTransferInfo.completed && (audio.playbackState === MediaPlayer.StoppedState || audio.playbackState === MediaPlayer.PausedState) + PropertyChanges { + target: playButton + icon.name: "media-playback-start" + onClicked: { + audio.source = root.progressInfo.localPath; + audio.play() + } + } + }, + State { + name: "playing" + when: root.fileTransferInfo.completed && audio.playbackState === MediaPlayer.PlayingState + + PropertyChanges { + target: playButton + + icon.name: "media-playback-pause" + + onClicked: audio.pause() + } + } + ] + + RowLayout { + QQC2.ToolButton { + id: playButton + } + QQC2.Label { + text: root.display + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + QQC2.ProgressBar { + id: downloadBar + visible: false + Layout.fillWidth: true + from: 0 + to: root.mediaInfo.size + value: root.fileTransferInfo.progress + } + RowLayout { + visible: audio.hasAudio + + QQC2.Slider { + Layout.fillWidth: true + from: 0 + to: audio.duration + value: audio.position + onMoved: audio.seek(value) + } + + QQC2.Label { + visible: root.maxContentWidth > Kirigami.Units.gridUnit * 12 + + text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration) + } + } + QQC2.Label { + Layout.alignment: Qt.AlignRight + Layout.rightMargin: Kirigami.Units.smallSpacing + visible: audio.hasAudio && root.maxContentWidth < Kirigami.Units.gridUnit * 12 + + text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration) + } +} diff --git a/src/qml/AudioDelegate.qml b/src/qml/AudioDelegate.qml deleted file mode 100644 index c9e29a421..000000000 --- a/src/qml/AudioDelegate.qml +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Tobias Fella -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Layouts -import QtMultimedia - -import org.kde.coreaddons -import org.kde.kirigami as Kirigami - -import org.kde.neochat - -/** - * @brief A timeline delegate for an audio message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be audio/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be audio-xxx). - * - size - The file size in bytes. - * - duration - The length in seconds of the audio media. - */ - required property var mediaInfo - - /** - * @brief Whether the media has been downloaded. - */ - readonly property bool downloaded: root.progressInfo && root.progressInfo.completed - onDownloadedChanged: audio.play() - - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) - - bubbleContent: ColumnLayout { - MediaPlayer { - id: audio - onErrorOccurred: (error, errorString) => console.warn("Audio playback error:" + error + errorString) - audioOutput: AudioOutput {} - } - - states: [ - State { - name: "notDownloaded" - when: !root.progressInfo.completed && !root.progressInfo.active - - PropertyChanges { - target: playButton - icon.name: "media-playback-start" - onClicked: root.room.downloadFile(root.eventId) - } - }, - State { - name: "downloading" - when: root.progressInfo.active && !root.progressInfo.completed - PropertyChanges { - target: downloadBar - visible: true - } - PropertyChanges { - target: playButton - icon.name: "media-playback-stop" - onClicked: { - root.room.cancelFileTransfer(root.eventId); - } - } - }, - State { - name: "paused" - when: root.progressInfo.completed && (audio.playbackState === MediaPlayer.StoppedState || audio.playbackState === MediaPlayer.PausedState) - PropertyChanges { - target: playButton - icon.name: "media-playback-start" - onClicked: { - audio.source = root.progressInfo.localPath; - audio.play(); - } - } - }, - State { - name: "playing" - when: root.progressInfo.completed && audio.playbackState === MediaPlayer.PlayingState - - PropertyChanges { - target: playButton - - icon.name: "media-playback-pause" - - onClicked: audio.pause() - } - } - ] - - RowLayout { - QQC2.ToolButton { - id: playButton - } - QQC2.Label { - text: root.display - wrapMode: Text.Wrap - Layout.fillWidth: true - } - } - QQC2.ProgressBar { - id: downloadBar - visible: false - Layout.fillWidth: true - from: 0 - to: root.mediaInfo.size - value: root.progressInfo.progress - } - RowLayout { - visible: audio.hasAudio - - QQC2.Slider { - Layout.fillWidth: true - from: 0 - to: audio.duration - value: audio.position - onMoved: audio.seek(value) - } - - QQC2.Label { - visible: root.contentMaxWidth > Kirigami.Units.gridUnit * 12 - - text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration) - } - } - QQC2.Label { - Layout.alignment: Qt.AlignRight - Layout.rightMargin: Kirigami.Units.smallSpacing - visible: audio.hasAudio && root.contentMaxWidth < Kirigami.Units.gridUnit * 12 - - text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration) - } - } -} diff --git a/src/qml/Bubble.qml b/src/qml/Bubble.qml index 1087e1e73..c8ea6fa61 100644 --- a/src/qml/Bubble.qml +++ b/src/qml/Bubble.qml @@ -22,6 +22,11 @@ import org.kde.neochat QQC2.Control { id: root + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + /** * @brief The message author. * @@ -61,70 +66,28 @@ QQC2.Control { property bool showHighlight: false /** - * @brief The main delegate content item to show in the bubble. + * @brief The model to visualise the content of the message. */ - property Item content + required property MessageContentModel contentModel /** - * @brief Whether this message is replying to another. - */ - property bool isReply: false - - /** - * @brief The matrix ID of the reply event. - */ - required property var replyId - - /** - * @brief The reply author. + * @brief The ActionsHandler object to use. * - * This should consist of the following: - * - id - The matrix ID of the reply author. - * - isLocalUser - Whether the reply author is the local user. - * - avatarSource - The mxc URL for the reply author's avatar in the current room. - * - avatarMediaId - The media ID of the reply author's avatar. - * - avatarUrl - The mxc URL for the reply author's avatar. - * - displayName - The display name of the reply author. - * - display - The name of the reply author. - * - color - The color for the reply author. - * - object - The Quotient::User object for the reply author. - * - * @sa Quotient::User + * This is expected to have the correct room set otherwise messages will be sent + * to the wrong room. */ - required property var replyAuthor - - /** - * @brief The delegate type of the message replied to. - */ - required property int replyDelegateType - - /** - * @brief The display text of the message replied to. - */ - required property string replyDisplay - - /** - * @brief The media info for the reply event. - * - * This could be an image, audio, video or file. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media. - * - mimeIcon - The MIME icon name. - * - size - The file size in bytes. - * - duration - The length in seconds of the audio media (audio/video only). - * - width - The width in pixels of the audio media (image/video only). - * - height - The height in pixels of the audio media (image/video only). - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). - */ - required property var replyMediaInfo + property ActionsHandler actionsHandler /** * @brief Whether the bubble background should be shown. */ property alias showBackground: bubbleBackground.visible + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + /** * @brief The maximum width that the bubble's content can be. */ @@ -135,11 +98,26 @@ QQC2.Control { */ signal replyClicked(string eventID) + /** + * @brief The user selected text has changed. + */ + signal selectedTextChanged(string selectedText) + + /** + * @brief Request a context menu be show for the message. + */ + signal showMessageMenu() + contentItem: ColumnLayout { + id: contentColumn + spacing: Kirigami.Units.smallSpacing RowLayout { + id: headerRow Layout.maximumWidth: root.maxContentWidth + implicitHeight: Math.max(nameButton.implicitHeight, timeLabel.implicitHeight) visible: root.showAuthor QQC2.AbstractButton { + id: nameButton Layout.fillWidth: true contentItem: QQC2.Label { text: root.author.displayName @@ -152,6 +130,7 @@ QQC2.Control { onClicked: RoomManager.resolveResource(root.author.id, "mention") } QQC2.Label { + id: timeLabel text: root.timeString horizontalAlignment: Text.AlignRight color: Kirigami.Theme.disabledTextColor @@ -164,35 +143,19 @@ QQC2.Control { } } } - Loader { - id: replyLoader - Layout.fillWidth: true - Layout.maximumWidth: root.maxContentWidth + Repeater { + id: contentRepeater + model: root.contentModel + delegate: MessageComponentChooser { + room: root.room + actionsHandler: root.actionsHandler + timeline: root.timeline + maxContentWidth: root.maxContentWidth - active: root.isReply && root.replyDelegateType !== DelegateType.Other - visible: active - - sourceComponent: ReplyComponent { - author: root.replyAuthor - type: root.replyDelegateType - display: root.replyDisplay - mediaInfo: root.replyMediaInfo - contentMaxWidth: root.maxContentWidth + onReplyClicked: (eventId) => {root.replyClicked(eventId)} + onSelectedTextChanged: (selectedText) => {root.selectedTextChanged(selectedText);} + onShowMessageMenu: root.showMessageMenu() } - - Connections { - target: replyLoader.item - function onReplyClicked() { - replyClicked(root.replyId); - } - } - } - Item { - id: contentParent - Layout.fillWidth: true - Layout.maximumWidth: root.maxContentWidth - implicitWidth: root.content ? root.content.implicitWidth : 0 - implicitHeight: root.content ? root.content.implicitHeight : 0 } } @@ -220,12 +183,4 @@ QQC2.Control { } } } - - onContentChanged: { - if (!root.content) { - return; - } - root.content.parent = contentParent; - root.content.anchors.fill = contentParent; - } } diff --git a/src/qml/EncryptedComponent.qml b/src/qml/EncryptedComponent.qml new file mode 100644 index 000000000..322564979 --- /dev/null +++ b/src/qml/EncryptedComponent.qml @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2021 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +/** + * @brief A component for an encrypted message that can't be decrypted. + */ +TextEdit { + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + text: i18n("This message is encrypted and the sender has not shared the key with this device.") + color: Kirigami.Theme.disabledTextColor + selectedTextColor: Kirigami.Theme.highlightedTextColor + selectionColor: Kirigami.Theme.highlightColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize + selectByMouse: !Kirigami.Settings.isMobile + readOnly: true + wrapMode: Text.WordWrap + textFormat: Text.RichText +} diff --git a/src/qml/EncryptedDelegate.qml b/src/qml/EncryptedDelegate.qml deleted file mode 100644 index 1b32c8849..000000000 --- a/src/qml/EncryptedDelegate.qml +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Tobias Fella -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Layouts - -import org.kde.kirigami as Kirigami -import org.kde.neochat -import org.kde.neochat.config - -/** - * @brief A timeline delegate for an encrypted message that can't be decrypted. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: encryptedDelegate - - bubbleContent: TextEdit { - text: i18n("This message is encrypted and the sender has not shared the key with this device.") - color: Kirigami.Theme.disabledTextColor - selectedTextColor: Kirigami.Theme.highlightedTextColor - selectionColor: Kirigami.Theme.highlightColor - font.pointSize: Kirigami.Theme.defaultFont.pointSize - selectByMouse: !Kirigami.Settings.isMobile - readOnly: true - wrapMode: Text.WordWrap - textFormat: Text.RichText - Layout.leftMargin: Config.showAvatarInTimeline ? Kirigami.Units.largeSpacing : 0 - } -} diff --git a/src/qml/EventDelegate.qml b/src/qml/EventDelegate.qml index e29be02f4..f52a9773a 100644 --- a/src/qml/EventDelegate.qml +++ b/src/qml/EventDelegate.qml @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2021 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham // SPDX-License-Identifier: GPL-3.0-only import QtQuick @@ -23,65 +24,9 @@ DelegateChooser { delegate: StateDelegate {} } - DelegateChoice { - roleValue: DelegateType.Emote - delegate: TextDelegate { - room: root.room - } - } - DelegateChoice { roleValue: DelegateType.Message - delegate: TextDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Notice - delegate: TextDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Image - delegate: ImageDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Sticker - delegate: ImageDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Audio - delegate: AudioDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Video - delegate: VideoDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.File - delegate: FileDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Encrypted - delegate: EncryptedDelegate { + delegate: MessageDelegate { room: root.room } } @@ -91,27 +36,6 @@ DelegateChooser { delegate: ReadMarkerDelegate {} } - DelegateChoice { - roleValue: DelegateType.Poll - delegate: PollDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.Location - delegate: LocationDelegate { - room: root.room - } - } - - DelegateChoice { - roleValue: DelegateType.LiveLocation - delegate: LiveLocationDelegate { - room: root.room - } - } - DelegateChoice { roleValue: DelegateType.Loading delegate: LoadingDelegate {} diff --git a/src/qml/FileComponent.qml b/src/qml/FileComponent.qml new file mode 100644 index 000000000..2ca7675b2 --- /dev/null +++ b/src/qml/FileComponent.qml @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2018-2019 Black Hat +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-3.0-only + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import Qt.labs.platform +import Qt.labs.qmlmodels + +import org.kde.coreaddons +import org.kde.kirigami as Kirigami + +import org.kde.neochat +import org.kde.neochat.config + +/** + * @brief A component to show a file from a message. + */ +ColumnLayout { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be image/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be image-xxx). + * - size - The file size in bytes. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + + /** + * @brief FileTransferInfo for any downloading files. + */ + required property var fileTransferInfo + + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed + onDownloadedChanged: { + itineraryModel.path = root.fileTransferInfo.localPath + if (autoOpenFile) { + openSavedFile(); + } + } + + /** + * @brief Whether the file should be automatically opened when downloaded. + */ + property bool autoOpenFile: false + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + function saveFileAs() { + const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay) + dialog.open() + dialog.currentFile = dialog.folder + "/" + root.room.fileNameToDownload(root.eventId) + } + + function openSavedFile() { + UrlHelper.openUrl(root.fileTransferInfo.localPath); + } + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + spacing: Kirigami.Units.largeSpacing + + RowLayout { + spacing: Kirigami.Units.largeSpacing + + states: [ + State { + name: "downloadedInstant" + when: root.fileTransferInfo.completed && autoOpenFile + + PropertyChanges { + target: openButton + icon.name: "document-open" + onClicked: openSavedFile() + } + + PropertyChanges { + target: downloadButton + icon.name: "download" + QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download") + onClicked: saveFileAs() + } + }, + State { + name: "downloaded" + when: root.fileTransferInfo.completed && !autoOpenFile + + PropertyChanges { + target: openButton + visible: false + } + + PropertyChanges { + target: downloadButton + icon.name: "document-open" + QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") + onClicked: openSavedFile() + } + }, + State { + name: "downloading" + when: root.fileTransferInfo.active + + PropertyChanges { + target: openButton + visible: false + } + + PropertyChanges { + target: sizeLabel + text: i18nc("file download progress", "%1 / %2", Format.formatByteSize(root.fileTransferInfo.progress), Format.formatByteSize(root.fileTransferInfo.total)) + } + PropertyChanges { + target: downloadButton + icon.name: "media-playback-stop" + QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download") + onClicked: root.room.cancelFileTransfer(root.eventId) + } + }, + State { + name: "raw" + when: true + + PropertyChanges { + target: downloadButton + onClicked: root.saveFileAs() + } + } + ] + + Kirigami.Icon { + source: root.mediaInfo.mimeIcon + fallback: "unknown" + } + + ColumnLayout { + spacing: 0 + QQC2.Label { + Layout.fillWidth: true + text: root.display + wrapMode: Text.Wrap + elide: Text.ElideRight + } + QQC2.Label { + id: sizeLabel + Layout.fillWidth: true + text: Format.formatByteSize(root.mediaInfo.size) + opacity: 0.7 + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + QQC2.Button { + id: openButton + icon.name: "document-open" + onClicked: { + autoOpenFile = true; + root.room.downloadTempFile(root.eventId); + } + + QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.Button { + id: downloadButton + icon.name: "download" + + QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + Component { + id: fileDialog + + FileDialog { + fileMode: FileDialog.SaveFile + folder: Config.lastSaveDirectory.length > 0 ? Config.lastSaveDirectory : StandardPaths.writableLocation(StandardPaths.DownloadLocation) + onAccepted: { + Config.lastSaveDirectory = folder + Config.save() + if (autoOpenFile) { + UrlHelper.copyTo(root.fileTransferInfo.localPath, file) + } else { + root.room.download(root.eventId, file); + } + } + } + } + } + Repeater { + id: itinerary + model: ItineraryModel { + id: itineraryModel + connection: root.room.connection + } + delegate: DelegateChooser { + role: "type" + DelegateChoice { + roleValue: "TrainReservation" + delegate: ColumnLayout { + Kirigami.Separator { + Layout.fillWidth: true + } + RowLayout { + QQC2.Label { + text: model.name + } + QQC2.Label { + text: model.coach ? i18n("Coach: %1, Seat: %2", model.coach, model.seat) : "" + visible: model.coach + opacity: 0.7 + } + } + RowLayout { + Layout.fillWidth: true + ColumnLayout { + QQC2.Label { + text: model.departureStation + (model.departurePlatform ? (" [" + model.departurePlatform + "]") : "") + } + QQC2.Label { + text: model.departureTime + opacity: 0.7 + } + } + Item { + Layout.fillWidth: true + } + ColumnLayout { + QQC2.Label { + text: model.arrivalStation + (model.arrivalPlatform ? (" [" + model.arrivalPlatform + "]") : "") + } + QQC2.Label { + text: model.arrivalTime + opacity: 0.7 + Layout.alignment: Qt.AlignRight + } + } + } + } + } + DelegateChoice { + roleValue: "LodgingReservation" + delegate: ColumnLayout { + Kirigami.Separator { + Layout.fillWidth: true + } + QQC2.Label { + text: model.name + } + QQC2.Label { + text: i18nc(" - ", "%1 - %2", model.startTime, model.endTime) + } + QQC2.Label { + text: model.address + } + } + } + } + } + QQC2.Button { + icon.name: "map-globe" + text: i18nc("@action", "Send to KDE Itinerary") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: itineraryModel.sendToItinerary() + visible: itinerary.count > 0 + } +} diff --git a/src/qml/FileDelegate.qml b/src/qml/FileDelegate.qml deleted file mode 100644 index 79d569d83..000000000 --- a/src/qml/FileDelegate.qml +++ /dev/null @@ -1,277 +0,0 @@ -// SPDX-FileCopyrightText: 2018-2019 Black Hat -// SPDX-License-Identifier: GPL-3.0-only - -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Layouts -import Qt.labs.platform -import Qt.labs.qmlmodels - -import org.kde.coreaddons -import org.kde.kirigami as Kirigami - -import org.kde.neochat -import org.kde.neochat.config - -/** - * @brief A timeline delegate for an file message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media. - * - mimeIcon - The MIME icon name. - * - size - The file size in bytes. - */ - required property var mediaInfo - - /** - * @brief Whether the media has been downloaded. - */ - readonly property bool downloaded: root.progressInfo && root.progressInfo.completed - - /** - * @brief Whether the file should be automatically opened when downloaded. - */ - property bool autoOpenFile: false - - onDownloadedChanged: { - itineraryModel.path = root.progressInfo.localPath; - if (autoOpenFile) { - openSavedFile(); - } - } - - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) - - function saveFileAs() { - const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay); - dialog.open(); - dialog.currentFile = dialog.folder + "/" + root.room.fileNameToDownload(root.eventId); - } - - function openSavedFile() { - UrlHelper.openUrl(root.progressInfo.localPath); - } - - bubbleContent: ColumnLayout { - spacing: Kirigami.Units.largeSpacing - RowLayout { - spacing: Kirigami.Units.largeSpacing - - states: [ - State { - name: "downloadedInstant" - when: root.progressInfo.completed && autoOpenFile - - PropertyChanges { - target: openButton - icon.name: "document-open" - onClicked: openSavedFile() - } - - PropertyChanges { - target: downloadButton - icon.name: "download" - QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download") - onClicked: saveFileAs() - } - }, - State { - name: "downloaded" - when: root.progressInfo.completed && !autoOpenFile - - PropertyChanges { - target: openButton - visible: false - } - - PropertyChanges { - target: downloadButton - icon.name: "document-open" - QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") - onClicked: openSavedFile() - } - }, - State { - name: "downloading" - when: root.progressInfo.active - - PropertyChanges { - target: openButton - visible: false - } - - PropertyChanges { - target: sizeLabel - text: i18nc("file download progress", "%1 / %2", Format.formatByteSize(root.progressInfo.progress), Format.formatByteSize(root.progressInfo.total)) - } - PropertyChanges { - target: downloadButton - icon.name: "media-playback-stop" - QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download") - onClicked: root.room.cancelFileTransfer(root.eventId) - } - }, - State { - name: "raw" - when: true - - PropertyChanges { - target: downloadButton - onClicked: root.saveFileAs() - } - } - ] - - Kirigami.Icon { - source: root.mediaInfo.mimeIcon - fallback: "unknown" - } - - ColumnLayout { - spacing: 0 - QQC2.Label { - Layout.fillWidth: true - text: root.display - wrapMode: Text.Wrap - elide: Text.ElideRight - } - QQC2.Label { - id: sizeLabel - Layout.fillWidth: true - text: Format.formatByteSize(root.mediaInfo.size) - opacity: 0.7 - elide: Text.ElideRight - maximumLineCount: 1 - } - } - - QQC2.Button { - id: openButton - icon.name: "document-open" - onClicked: { - autoOpenFile = true; - root.room.downloadTempFile(root.eventId); - } - - QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - - QQC2.Button { - id: downloadButton - icon.name: "download" - - QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - - Component { - id: fileDialog - - FileDialog { - fileMode: FileDialog.SaveFile - folder: Config.lastSaveDirectory.length > 0 ? Config.lastSaveDirectory : StandardPaths.writableLocation(StandardPaths.DownloadLocation) - onAccepted: { - Config.lastSaveDirectory = folder; - Config.save(); - if (autoOpenFile) { - UrlHelper.copyTo(root.progressInfo.localPath, file); - } else { - root.room.download(root.eventId, file); - } - } - } - } - } - Repeater { - id: itinerary - model: ItineraryModel { - id: itineraryModel - connection: root.room.connection - } - delegate: DelegateChooser { - role: "type" - DelegateChoice { - roleValue: "TrainReservation" - delegate: ColumnLayout { - Kirigami.Separator { - Layout.fillWidth: true - } - RowLayout { - QQC2.Label { - text: model.name - } - QQC2.Label { - text: model.coach ? i18n("Coach: %1, Seat: %2", model.coach, model.seat) : "" - visible: model.coach - opacity: 0.7 - } - } - RowLayout { - Layout.fillWidth: true - ColumnLayout { - QQC2.Label { - text: model.departureStation + (model.departurePlatform ? (" [" + model.departurePlatform + "]") : "") - } - QQC2.Label { - text: model.departureTime - opacity: 0.7 - } - } - Item { - Layout.fillWidth: true - } - ColumnLayout { - QQC2.Label { - text: model.arrivalStation + (model.arrivalPlatform ? (" [" + model.arrivalPlatform + "]") : "") - } - QQC2.Label { - text: model.arrivalTime - opacity: 0.7 - Layout.alignment: Qt.AlignRight - } - } - } - } - } - DelegateChoice { - roleValue: "LodgingReservation" - delegate: ColumnLayout { - Kirigami.Separator { - Layout.fillWidth: true - } - QQC2.Label { - text: model.name - } - QQC2.Label { - text: i18nc(" - ", "%1 - %2", model.startTime, model.endTime) - } - QQC2.Label { - text: model.address - } - } - } - } - } - QQC2.Button { - icon.name: "map-globe" - text: i18nc("@action", "Send to KDE Itinerary") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: text - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - onClicked: itineraryModel.sendToItinerary() - visible: itinerary.count > 0 - } - } -} diff --git a/src/qml/ImageComponent.qml b/src/qml/ImageComponent.qml new file mode 100644 index 000000000..2126baaca --- /dev/null +++ b/src/qml/ImageComponent.qml @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2018-2020 Black Hat +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-3.0-only + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +/** + * @brief A component to show the image from a message. + */ +Item { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be image/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be image-xxx). + * - size - The file size in bytes. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + + /** + * @brief FileTransferInfo for any downloading files. + */ + required property var fileTransferInfo + + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + implicitWidth: mediaSizeHelper.currentSize.width + implicitHeight: mediaSizeHelper.currentSize.height + + Loader { + id: imageLoader + + anchors.fill: parent + + active: !root.mediaInfo.animated + sourceComponent: Image { + source: root.mediaInfo.source + sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio + sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio + + fillMode: Image.PreserveAspectFit + } + } + + Loader { + id: animatedImageLoader + + anchors.fill: parent + + active: root?.mediaInfo.animated ?? false + sourceComponent: AnimatedImage { + source: root.mediaInfo.source + + fillMode: Image.PreserveAspectFit + + paused: !applicationWindow().active + } + } + + Image { + anchors.fill: parent + source: root?.mediaInfo.tempInfo.source ?? "" + visible: _private.imageItem.status !== Image.Ready + } + + QQC2.ToolTip.text: root.display + QQC2.ToolTip.visible: hoverHandler.hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + + HoverHandler { + id: hoverHandler + } + + Rectangle { + anchors.fill: parent + + visible: _private.imageItem.status !== Image.Ready + + color: "#BB000000" + + QQC2.ProgressBar { + anchors.centerIn: parent + + width: parent.width * 0.8 + + from: 0 + to: 1.0 + value: _private.imageItem.progress + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds + onTapped: { + root.QQC2.ToolTip.hide() + if (root.mediaInfo.animated) { + _private.imageItem.paused = true + } + root.timeline.interactive = false + // We need to make sure the index is that of the MediaMessageFilterModel. + if (root.timeline.model instanceof MessageFilterModel) { + RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)) + } else { + RoomManager.maximizeMedia(root.index) + } + } + } + + function downloadAndOpen() { + if (_private.downloaded) { + openSavedFile() + } else { + openOnFinished = true + root.room.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId)) + } + } + + function openSavedFile() { + if (UrlHelper.openUrl(root.fileTransferInfo.localPath)) return; + if (UrlHelper.openUrl(root.fileTransferInfo.localDir)) return; + } + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.maxContentWidth + mediaWidth: root?.mediaInfo.width ?? 0 + mediaHeight: root?.mediaInfo.height ?? 0 + } + + QtObject { + id: _private + readonly property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item + + // The space available for the component after taking away the border + readonly property real downloaded: root.fileTransferInfo && root.fileTransferInfo.completed + } +} diff --git a/src/qml/ImageDelegate.qml b/src/qml/ImageDelegate.qml deleted file mode 100644 index e218a0997..000000000 --- a/src/qml/ImageDelegate.qml +++ /dev/null @@ -1,169 +0,0 @@ -// SPDX-FileCopyrightText: 2018-2020 Black Hat -// SPDX-License-Identifier: GPL-3.0-only - -import QtQuick -import QtQuick.Window -import QtQuick.Controls as QQC2 -import QtQuick.Layouts -import QtQml.Models - -import org.kde.kirigami as Kirigami - -import org.kde.neochat - -/** - * @brief A timeline delegate for an image message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be image/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be image-xxx). - * - size - The file size in bytes. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. - */ - required property var mediaInfo - - /** - * @brief Whether the media has been downloaded. - */ - readonly property bool downloaded: root.progressInfo && root.progressInfo.completed - - /** - * @brief Whether the image should be automatically opened when downloaded. - */ - property bool openOnFinished: false - - /** - * @brief The maximum width of the image. - */ - readonly property var maxWidth: Kirigami.Units.gridUnit * 30 - - /** - * @brief The maximum height of the image. - */ - readonly property var maxHeight: Kirigami.Units.gridUnit * 30 - - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) - - bubbleContent: Item { - id: imageContainer - - property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item - - implicitWidth: mediaSizeHelper.currentSize.width - implicitHeight: mediaSizeHelper.currentSize.height - - Loader { - id: imageLoader - - anchors.fill: parent - - active: !root.mediaInfo.animated - sourceComponent: Image { - source: root.mediaInfo.source - sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio - sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio - - fillMode: Image.PreserveAspectFit - } - } - - Loader { - id: animatedImageLoader - - anchors.fill: parent - - active: root.mediaInfo.animated - sourceComponent: AnimatedImage { - source: root.mediaInfo.source - - fillMode: Image.PreserveAspectFit - - paused: !applicationWindow().active - } - } - - Image { - anchors.fill: parent - source: root.mediaInfo.tempInfo.source - visible: imageContainer.imageItem.status !== Image.Ready - } - - QQC2.ToolTip.text: root.display - QQC2.ToolTip.visible: hoverHandler.hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - - HoverHandler { - id: hoverHandler - } - - Rectangle { - anchors.fill: parent - - visible: (root.progressInfo.active && !downloaded) || imageContainer.imageItem.status !== Image.Ready - - color: "#BB000000" - - QQC2.ProgressBar { - anchors.centerIn: parent - - width: parent.width * 0.8 - - from: 0 - to: root.progressInfo.total - value: root.progressInfo.progress - } - } - - TapHandler { - acceptedButtons: Qt.LeftButton - gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds - onTapped: { - imageContainer.QQC2.ToolTip.hide(); - if (root.mediaInfo.animated) { - imageContainer.imageItem.paused = true; - } - root.ListView.view.interactive = false; - // We need to make sure the index is that of the MediaMessageFilterModel. - if (root.ListView.view.model instanceof MessageFilterModel) { - RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)); - } else { - RoomManager.maximizeMedia(root.index); - } - } - } - - function downloadAndOpen() { - if (downloaded) { - openSavedFile(); - } else { - openOnFinished = true; - root.room.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId)); - } - } - - function openSavedFile() { - if (UrlHelper.openUrl(root.progressInfo.localPath)) - return; - if (UrlHelper.openUrl(root.progressInfo.localDir)) - return; - } - - MediaSizeHelper { - id: mediaSizeHelper - contentMaxWidth: root.contentMaxWidth - mediaWidth: root.mediaInfo.width - mediaHeight: root.mediaInfo.height - } - } -} diff --git a/src/qml/LinkPreviewComponent.qml b/src/qml/LinkPreviewComponent.qml new file mode 100644 index 000000000..a6971998e --- /dev/null +++ b/src/qml/LinkPreviewComponent.qml @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2022 Bharadwaj Raju +// SPDX-FileCopyrightText: 2023-2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +/** + * @brief A component to show a link preview from a message. + */ +QQC2.Control { + id: root + + /** + * @brief The link preview properties. + * + * This is a list or object containing the following: + * - url - The URL being previewed. + * - loaded - Whether the URL preview has been loaded. + * - title - the title of the URL preview. + * - description - the description of the URL preview. + * - imageSource - a source URL for the preview image. + */ + required property var linkPreviewer + + /** + * @brief Standard height for the link preview. + * + * When the content of the link preview is larger than this it will be + * elided/hidden until maximized. + */ + property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2 + + property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + contentItem: RowLayout { + id: contentRow + spacing: Kirigami.Units.smallSpacing + + Rectangle { + id: separator + Layout.fillHeight: true + width: Kirigami.Units.smallSpacing + color: Kirigami.Theme.highlightColor + } + Image { + id: previewImage + Layout.preferredWidth: root.defaultHeight + Layout.preferredHeight: root.defaultHeight + visible: root.linkPreviewer.imageSource.length > 0 + source: root.linkPreviewer.imageSource + fillMode: Image.PreserveAspectFit + } + ColumnLayout { + id: column + implicitWidth: Math.max(linkPreviewTitle.implicitWidth, linkPreviewDescription.implicitWidth) + spacing: Kirigami.Units.smallSpacing + Kirigami.Heading { + id: linkPreviewTitle + Layout.fillWidth: true + level: 3 + wrapMode: Text.Wrap + textFormat: Text.RichText + text: " + " + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("–", "—") + "" + onLinkActivated: RoomManager.resolveResource(link, "join") + + TextMetrics { + id: titleTextMetrics + text: root.linkPreviewer.title + font: linkPreviewTitle.font + elide: Text.ElideRight + elideWidth: (linkPreviewTitle.availableWidth()) * 3 + } + + function availableWidth() { + let previewImageWidth = (previewImage.visible ? previewImage.width + contentRow.spacing : 0); + return root.maxContentWidth - contentRow.spacing - separator.width - previewImageWidth; + } + } + QQC2.Label { + id: linkPreviewDescription + Layout.fillWidth: true + Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing + visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked + text: linkPreviewer.description + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + } + + QQC2.Button { + id: maximizeButton + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: root.hovered && (root.truncated || checked) + checkable: true + text: checked ? i18n("Shrink preview") : i18n("Expand preview") + icon.name: checked ? "go-up" : "go-down" + display: QQC2.AbstractButton.IconOnly + + QQC2.ToolTip { + text: maximizeButton.text + visible: hovered + delay: Kirigami.Units.toolTipDelay + } + } +} diff --git a/src/qml/LinkPreviewDelegate.qml b/src/qml/LinkPreviewDelegate.qml deleted file mode 100644 index 52845cdee..000000000 --- a/src/qml/LinkPreviewDelegate.qml +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Bharadwaj Raju -// SPDX-FileCopyrightText: 2023 James Graham -// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL - -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Layouts - -import org.kde.kirigami as Kirigami - -import org.kde.neochat - -Loader { - id: root - - /** - * @brief The link preview properties. - * - * This is a list or object containing the following: - * - url - The URL being previewed. - * - loaded - Whether the URL preview has been loaded. - * - title - the title of the URL preview. - * - description - the description of the URL preview. - * - imageSource - a source URL for the preview image. - */ - required property var linkPreviewer - - /** - * @brief Standard height for the link preview. - * - * When the content of the link preview is larger than this it will be - * elided/hidden until maximized. - */ - property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2 - - /** - * @brief Whether the loading indicator should animate if visible. - */ - property bool indicatorEnabled: false - - visible: active - sourceComponent: linkPreviewer && linkPreviewer.loaded ? linkPreviewComponent : loadingComponent - - Component { - id: linkPreviewComponent - QQC2.Control { - id: componentRoot - property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible - - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - - contentItem: RowLayout { - spacing: Kirigami.Units.smallSpacing - - Rectangle { - Layout.fillHeight: true - width: Kirigami.Units.smallSpacing - color: Kirigami.Theme.highlightColor - } - Image { - visible: root.linkPreviewer.imageSource - Layout.maximumHeight: root.defaultHeight - Layout.maximumWidth: root.defaultHeight - source: root.linkPreviewer.imageSource - fillMode: Image.PreserveAspectFit - } - ColumnLayout { - id: column - spacing: Kirigami.Units.smallSpacing - Kirigami.Heading { - id: linkPreviewTitle - Layout.fillWidth: true - level: 3 - wrapMode: Text.Wrap - textFormat: Text.RichText - text: " - " + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("–", "—") + "" - onLinkActivated: RoomManager.resolveResource(link, "join") - - TextMetrics { - id: titleTextMetrics - text: root.linkPreviewer.title - font: linkPreviewTitle.font - elide: Text.ElideRight - elideWidth: (linkPreviewTitle.width - Kirigami.Units.largeSpacing * 2.5) * 3 - } - } - QQC2.Label { - id: linkPreviewDescription - Layout.fillWidth: true - Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing - visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked - text: linkPreviewer.description - wrapMode: Text.Wrap - elide: Text.ElideRight - } - } - } - - QQC2.Button { - id: maximizeButton - anchors.right: parent.right - anchors.bottom: parent.bottom - visible: componentRoot.hovered && (componentRoot.truncated || checked) - checkable: true - text: checked ? i18n("Shrink preview") : i18n("Expand preview") - icon.name: checked ? "go-up" : "go-down" - display: QQC2.AbstractButton.IconOnly - - QQC2.ToolTip { - text: maximizeButton.text - visible: hovered - delay: Kirigami.Units.toolTipDelay - } - } - } - } - - Component { - id: loadingComponent - RowLayout { - id: componentRoot - property bool truncated: false - - Rectangle { - Layout.fillHeight: true - width: Kirigami.Units.smallSpacing - color: Kirigami.Theme.highlightColor - } - QQC2.BusyIndicator { - running: root.indicatorEnabled - } - Kirigami.Heading { - Layout.fillWidth: true - Layout.minimumHeight: root.defaultHeight - level: 2 - text: i18n("Loading URL preview") - } - } - } -} diff --git a/src/qml/LiveLocationComponent.qml b/src/qml/LiveLocationComponent.qml new file mode 100644 index 000000000..e64938afc --- /dev/null +++ b/src/qml/LiveLocationComponent.qml @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2021 Tobias Fella +// SPDX-FileCopyrightText: 2023 Volker Krause +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtLocation +import QtPositioning + +import org.kde.neochat + +/** + * @brief A component to show a live location from a message. + */ +ColumnLayout { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + LiveLocationsModel { + id: liveLocationModel + eventId: root.eventId + room: root.room + } + MapView { + id: mapView + Layout.fillWidth: true + Layout.preferredWidth: root.maxContentWidth + Layout.preferredHeight: root.maxContentWidth / 16 * 9 + + map.center: QtPositioning.coordinate(liveLocationModel.boundingBox.y, liveLocationModel.boundingBox.x) + map.zoomLevel: 15 + + map.plugin: OsmLocationPlugin.plugin + + MapItemView { + model: liveLocationModel + delegate: LocationMapItem {} + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + let map = fullScreenMap.createObject(parent, {liveLocationModel: liveLocationModel}); + map.open() + } + onLongPressed: openMessageContext("") + } + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: openMessageContext("") + } + Connections { + target: mapView.map + function onCopyrightLinkActivated() { + Qt.openUrlExternally(link) + } + } + } + Component { + id: fullScreenMap + FullScreenMap {} + } + + TextComponent { + display: root.display + visible: root.display !== "" + } +} diff --git a/src/qml/LiveLocationDelegate.qml b/src/qml/LiveLocationDelegate.qml deleted file mode 100644 index 0e5557831..000000000 --- a/src/qml/LiveLocationDelegate.qml +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Tobias Fella -// SPDX-FileCopyrightText: 2023 Volker Krause -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtLocation -import QtPositioning - -import org.kde.neochat - -/** - * @brief A timeline delegate for a location message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - bubbleContent: ColumnLayout { - LiveLocationsModel { - id: liveLocationModel - eventId: root.eventId - room: root.room - } - MapView { - id: mapView - Layout.fillWidth: true - Layout.preferredHeight: root.contentMaxWidth / 16 * 9 - - map.center: QtPositioning.coordinate(liveLocationModel.boundingBox.y, liveLocationModel.boundingBox.x) - map.zoomLevel: 15 - - map.plugin: OsmLocationPlugin.plugin - - MapItemView { - model: liveLocationModel - delegate: LocationMapItem {} - } - - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: { - let map = fullScreenMap.createObject(parent, { - liveLocationModel: liveLocationModel - }); - map.open(); - } - onLongPressed: openMessageContext("") - } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: openMessageContext("") - } - Connections { - target: mapView.map - function onCopyrightLinkActivated() { - Qt.openUrlExternally(link); - } - } - } - Component { - id: fullScreenMap - FullScreenMap {} - } - - RichLabel { - textMessage: root.display - visible: root.display !== "" - } - } -} diff --git a/src/qml/LoadComponent.qml b/src/qml/LoadComponent.qml new file mode 100644 index 000000000..f20bc8281 --- /dev/null +++ b/src/qml/LoadComponent.qml @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +/** + * @brief A component to show a link preview loading from a message. + */ +RowLayout { + id: root + + required property int type + + /** + * @brief Standard height for the link preview. + * + * When the content of the link preview is larger than this it will be + * elided/hidden until maximized. + */ + property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2 + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + enum Type { + Reply, + LinkPreview + } + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + Rectangle { + Layout.fillHeight: true + width: Kirigami.Units.smallSpacing + color: Kirigami.Theme.highlightColor + } + QQC2.BusyIndicator {} + Kirigami.Heading { + Layout.fillWidth: true + Layout.minimumHeight: root.defaultHeight + verticalAlignment: Text.AlignVCenter + level: 2 + text: { + switch (root.type) { + case LoadComponent.Reply: + return i18n("Loading reply"); + case LoadComponent.LinkPreview: + return i18n("Loading URL preview"); + + } + } + } +} diff --git a/src/qml/LocationComponent.qml b/src/qml/LocationComponent.qml new file mode 100644 index 000000000..52552ae9c --- /dev/null +++ b/src/qml/LocationComponent.qml @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2021 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtLocation +import QtPositioning + +import org.kde.neochat + +/** + * @brief A component to show a location from a message. + */ +ColumnLayout { + id: root + + /** + * @brief The message author. + * + * This should consist of the following: + * - id - The matrix ID of the author. + * - isLocalUser - Whether the author is the local user. + * - avatarSource - The mxc URL for the author's avatar in the current room. + * - avatarMediaId - The media ID of the author's avatar. + * - avatarUrl - The mxc URL for the author's avatar. + * - displayName - The display name of the author. + * - display - The name of the author. + * - color - The color for the author. + * - object - The Quotient::User object for the author. + * + * @sa Quotient::User + */ + required property var author + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The latitude of the location marker in the message. + */ + required property real latitude + + /** + * @brief The longitude of the location marker in the message. + */ + required property real longitude + + /** + * @brief What type of marker the location message is. + * + * The main options are m.pin for a general location or m.self for a pin to show + * a user's location. + */ + required property string asset + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + MapView { + id: mapView + Layout.fillWidth: true + Layout.preferredWidth: root.maxContentWidth + Layout.preferredHeight: root.maxContentWidth / 16 * 9 + + map.center: QtPositioning.coordinate(root.latitude, root.longitude) + map.zoomLevel: 15 + + map.plugin: OsmLocationPlugin.plugin + + LocationMapItem { + latitude: root.latitude + longitude: root.longitude + asset: root.asset + author: root.author + isLive: true + heading: NaN + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + let map = fullScreenMap.createObject(parent, {latitude: root.latitude, longitude: root.longitude, asset: root.asset, author: root.author}); + map.open() + } + onLongPressed: openMessageContext("") + } + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: openMessageContext("") + } + Connections { + target: mapView.map + function onCopyrightLinkActivated() { + Qt.openUrlExternally(link) + } + } + } + Component { + id: fullScreenMap + FullScreenMap { } + } + + TextComponent { + display: root.display + visible: root.display !== "" + } +} diff --git a/src/qml/LocationDelegate.qml b/src/qml/LocationDelegate.qml deleted file mode 100644 index 207f0caf2..000000000 --- a/src/qml/LocationDelegate.qml +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Tobias Fella -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtLocation -import QtPositioning - -import org.kde.neochat - -/** - * @brief A timeline delegate for a location message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The latitude of the location marker in the message. - */ - required property real latitude - - /** - * @brief The longitude of the location marker in the message. - */ - required property real longitude - - /** - * @brief What type of marker the location message is. - * - * The main options are m.pin for a general location or m.self for a pin to show - * a user's location. - */ - required property string asset - - bubbleContent: ColumnLayout { - MapView { - id: mapView - Layout.fillWidth: true - Layout.preferredHeight: root.contentMaxWidth / 16 * 9 - - map.center: QtPositioning.coordinate(root.latitude, root.longitude) - map.zoomLevel: 15 - - map.plugin: OsmLocationPlugin.plugin - - LocationMapItem { - latitude: root.latitude - longitude: root.longitude - asset: root.asset - author: root.author - isLive: true - heading: NaN - } - - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: { - let map = fullScreenMap.createObject(parent, { - latitude: root.latitude, - longitude: root.longitude, - asset: root.asset, - author: root.author - }); - map.open(); - } - onLongPressed: openMessageContext("") - } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: openMessageContext("") - } - Connections { - target: mapView.map - function onCopyrightLinkActivated() { - Qt.openUrlExternally(link); - } - } - } - Component { - id: fullScreenMap - FullScreenMap {} - } - - RichLabel { - textMessage: root.display - visible: root.display !== "" - } - } -} diff --git a/src/qml/MessageComponentChooser.qml b/src/qml/MessageComponentChooser.qml new file mode 100644 index 000000000..8f37720b2 --- /dev/null +++ b/src/qml/MessageComponentChooser.qml @@ -0,0 +1,172 @@ +// 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 Qt.labs.qmlmodels + +import org.kde.neochat + +/** + * @brief Select a message component based on a MessageComponentType. + */ +DelegateChooser { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The ActionsHandler object to use. + * + * This is expected to have the correct room set otherwise messages will be sent + * to the wrong room. + */ + required property ActionsHandler actionsHandler + + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + /** + * @brief The reply has been clicked. + */ + signal replyClicked(string eventID) + + /** + * @brief The user selected text has changed. + */ + signal selectedTextChanged(string selectedText) + + /** + * @brief Request a context menu be show for the message. + */ + signal showMessageMenu() + + role: "componentType" + + DelegateChoice { + roleValue: MessageComponentType.Text + delegate: TextComponent { + maxContentWidth: root.maxContentWidth + onSelectedTextChanged: root.selectedTextChanged(selectedText); + onShowMessageMenu: root.showMessageMenu() + } + } + + DelegateChoice { + roleValue: MessageComponentType.Image + delegate: ImageComponent { + room: root.room + timeline: root.timeline + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Video + delegate: VideoComponent { + room: root.room + timeline: root.timeline + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Audio + delegate: AudioComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.File + delegate: FileComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Poll + delegate: PollComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Location + delegate: LocationComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.LiveLocation + delegate: LiveLocationComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Encrypted + delegate: EncryptedComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Reply + delegate: ReplyComponent { + maxContentWidth: root.maxContentWidth + onReplyClicked: (eventId) => {root.replyClicked(eventId)} + } + } + + DelegateChoice { + roleValue: MessageComponentType.ReplyLoad + delegate: LoadComponent { + type: LoadComponent.Reply + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.LinkPreview + delegate: LinkPreviewComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.LinkPreviewLoad + delegate: LoadComponent { + type: LoadComponent.LinkPreview + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Edit + delegate: MessageEditComponent { + room: root.room + actionsHandler: root.actionsHandler + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Other + delegate: Item {} + } +} diff --git a/src/qml/MessageDelegate.qml b/src/qml/MessageDelegate.qml index b790808ab..58afe8242 100644 --- a/src/qml/MessageDelegate.qml +++ b/src/qml/MessageDelegate.qml @@ -88,19 +88,9 @@ TimelineDelegate { property bool alwaysShowAuthor: false /** - * @brief The delegate type of the message. + * @brief The model to visualise the content of the message. */ - required property int delegateType - - /** - * @brief The display text of the message. - */ - required property string display - - /** - * @brief The display text of the message as plain text. - */ - required property string plainText + required property MessageContentModel contentModel /** * @brief The date of the event as a string. @@ -142,65 +132,10 @@ TimelineDelegate { */ required property bool showReadMarkers - /** - * @brief The matrix ID of the reply event. - */ - required property var replyId - - /** - * @brief The reply author. - * - * This should consist of the following: - * - id - The matrix ID of the reply author. - * - isLocalUser - Whether the reply author is the local user. - * - avatarSource - The mxc URL for the reply author's avatar in the current room. - * - avatarMediaId - The media ID of the reply author's avatar. - * - avatarUrl - The mxc URL for the reply author's avatar. - * - displayName - The display name of the reply author. - * - display - The name of the reply author. - * - color - The color for the reply author. - * - object - The Quotient::User object for the reply author. - * - * @sa Quotient::User - */ - required property var replyAuthor - - /** - * @brief The delegate type of the message replied to. - */ - required property int replyDelegateType - - /** - * @brief The display text of the message replied to. - */ - required property string replyDisplay - - /** - * @brief The media info for the reply event. - * - * This could be an image, audio, video or file. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media. - * - mimeIcon - The MIME icon name. - * - size - The file size in bytes. - * - duration - The length in seconds of the audio media (audio/video only). - * - width - The width in pixels of the audio media (image/video only). - * - height - The height in pixels of the audio media (image/video only). - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). - */ - required property var replyMediaInfo - required property bool isThreaded required property string threadRoot - /** - * @brief Whether this message is replying to another. - */ - required property bool isReply - /** * @brief Whether this message has a local user mention. */ @@ -211,13 +146,6 @@ TimelineDelegate { */ required property bool isPending - /** - * @brief Progress info when downloading files. - * - * @sa Quotient::FileTransferInfo - */ - required property var progressInfo - /** * @brief Whether an encrypted message is sent in a verified session. */ @@ -249,11 +177,6 @@ TimelineDelegate { */ readonly property alias hovered: bubble.hovered - /** - * @brief Open the context menu for the message. - */ - signal openContextMenu - /** * @brief Open the any message media externally. */ @@ -268,7 +191,7 @@ TimelineDelegate { /** * @brief The main delegate content item to show in the bubble. */ - property alias bubbleContent: bubble.content + property var bubbleContent /** * @brief Whether the bubble background is enabled. @@ -293,6 +216,11 @@ TimelineDelegate { */ property bool isTemporaryHighlighted: false + /** + * @brief The user selected text. + */ + property string selectedText: "" + onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) { temporaryHighlightTimer.start(); } @@ -329,12 +257,6 @@ TimelineDelegate { implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height) - Component.onCompleted: { - if (root.isReply && root.replyDelegateType === DelegateType.Other) { - root.room.loadReply(root.eventId, root.replyId); - } - } - // show hover actions onHoveredChanged: { if (hovered && !Kirigami.Settings.isMobile) { @@ -395,23 +317,24 @@ TimelineDelegate { } ] + room: root.room + author: root.author showAuthor: root.showAuthor || root.alwaysShowAuthor time: root.time timeString: root.timeString - showHighlight: root.showHighlight + contentModel: root.contentModel + actionsHandler: root.ListView.view?.actionsHandler ?? null + timeline: root.ListView.view - isReply: root.isReply - replyId: root.replyId - replyAuthor: root.replyAuthor - replyDelegateType: root.replyDelegateType - replyDisplay: root.replyDisplay - replyMediaInfo: root.replyMediaInfo + showHighlight: root.showHighlight onReplyClicked: eventId => { root.replyClicked(eventId); } + onSelectedTextChanged: (selectedText) => {root.selectedText = selectedText;} + onShowMessageMenu: _private.showMessageMenu() showBackground: root.cardBackground && !Config.compactLayout } @@ -424,12 +347,12 @@ TimelineDelegate { TapHandler { acceptedButtons: Qt.RightButton - onTapped: root.openContextMenu() + onTapped: _private.showMessageMenu() } TapHandler { acceptedButtons: Qt.LeftButton - onLongPressed: root.openContextMenu() + onLongPressed: _private.showMessageMenu() } } @@ -482,5 +405,9 @@ TimelineDelegate { * @brief Whether local user messages should be aligned right. */ property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout && !root.alwaysMaxWidth + + function showMessageMenu() { + RoomManager.viewEventMenu(root.eventId, root.room, root.selectedText) + } } } diff --git a/src/qml/MessageDelegateContextMenu.qml b/src/qml/MessageDelegateContextMenu.qml index 2fc40d3a7..90f8bef7b 100644 --- a/src/qml/MessageDelegateContextMenu.qml +++ b/src/qml/MessageDelegateContextMenu.qml @@ -59,7 +59,7 @@ Loader { /** * @brief The delegate type of the message. */ - required property int delegateType + required property int messageComponentType /** * @brief The display text of the message as plain text. @@ -96,7 +96,7 @@ Loader { currentRoom.editCache.editId = eventId; currentRoom.mainCache.replyId = ""; } - visible: author.isLocalUser && (root.delegateType === DelegateType.Emote || root.delegateType === DelegateType.Message) + visible: author.isLocalUser && (root.messageComponentType === MessageComponentType.Emote || root.messageComponentType === MessageComponentType.Message) }, Kirigami.Action { text: i18n("Reply") diff --git a/src/qml/MessageEditComponent.qml b/src/qml/MessageEditComponent.qml index 9cbd8d60e..3f212fef6 100644 --- a/src/qml/MessageEditComponent.qml +++ b/src/qml/MessageEditComponent.qml @@ -9,13 +9,19 @@ import org.kde.kirigami as Kirigami import org.kde.neochat +/** + * @brief A component to show an edit text field for a text message being edited. + */ QQC2.TextArea { id: root + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ required property NeoChatRoom room onRoomChanged: { _private.chatBarCache = room.editCache; - _private.chatBarCache.relationIdChanged.connect(_private.updateEditText); + _private.chatBarCache.relationIdChanged.connect(_private.updateEditText()); } /** @@ -26,10 +32,19 @@ QQC2.TextArea { */ required property ActionsHandler actionsHandler - property string messageId - property var minimumHeight: editButtons.height + topPadding + bottomPadding property var preferredWidth: editTextMetrics.advanceWidth + rightPadding + Kirigami.Units.smallSpacing + Kirigami.Units.gridUnit + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + Component.onCompleted: _private.updateEditText() + rightPadding: editButtons.width + editButtons.anchors.rightMargin * 2 color: Kirigami.Theme.textColor diff --git a/src/qml/MimeComponent.qml b/src/qml/MimeComponent.qml index e8b64b74d..1685b3700 100644 --- a/src/qml/MimeComponent.qml +++ b/src/qml/MimeComponent.qml @@ -7,6 +7,9 @@ import QtQuick.Layouts import org.kde.kirigami as Kirigami +/** + * @brief A component to show media based upon its mime type. + */ RowLayout { property alias mimeIconSource: icon.source property alias label: nameLabel.text diff --git a/src/qml/NeochatMaximizeComponent.qml b/src/qml/NeochatMaximizeComponent.qml index d60714fed..60c7bb05a 100644 --- a/src/qml/NeochatMaximizeComponent.qml +++ b/src/qml/NeochatMaximizeComponent.qml @@ -26,12 +26,6 @@ Components.AlbumMaximizeComponent { readonly property var currentTime: model.data(model.index(content.currentIndex, 0), MessageEventModel.TimeRole) - readonly property var currentDelegateType: model.data(model.index(content.currentIndex, 0), MessageEventModel.DelegateTypeRole) - - readonly property string currentPlainText: model.data(model.index(content.currentIndex, 0), MessageEventModel.PlainText) - - readonly property var currentMimeType: model.data(model.index(content.currentIndex, 0), MessageEventModel.MimeTypeRole) - readonly property var currentProgressInfo: model.data(model.index(content.currentIndex, 0), MessageEventModel.ProgressInfoRole) downloadAction: Components.DownloadAction { @@ -87,7 +81,8 @@ Components.AlbumMaximizeComponent { } } } - onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentAuthor, root.currentDelegateType, root.currentPlainText, "", "", root.currentMimeType, root.currentProgressInfo) + + onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentRoom) onSaveItem: { var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay); diff --git a/src/qml/PollComponent.qml b/src/qml/PollComponent.qml new file mode 100644 index 000000000..801048398 --- /dev/null +++ b/src/qml/PollComponent.qml @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt.labs.platform + +import org.kde.neochat + +/** + * @brief A component to show a poll from a message. + */ +ColumnLayout { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The poll handler for this poll. + * + * This contains the required information like what the question, answers and + * current number of votes for each is. + */ + required property var pollHandler + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + Label { + id: questionLabel + text: root.pollHandler.question + wrapMode: Text.Wrap + Layout.fillWidth: true + } + Repeater { + model: root.pollHandler.options + delegate: RowLayout { + Layout.fillWidth: true + CheckBox { + checked: root.pollHandler.answers[root.room.localUser.id] ? root.pollHandler.answers[root.room.localUser.id].includes(modelData["id"]) : false + onClicked: root.pollHandler.sendPollAnswer(root.eventId, modelData["id"]) + enabled: !root.pollHandler.hasEnded + } + Label { + text: modelData["org.matrix.msc1767.text"] + Layout.fillWidth: true + wrapMode: Text.Wrap + } + Label { + visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded + Layout.preferredWidth: contentWidth + text: root.pollHandler.counts[modelData["id"]] ?? "0" + } + } + } + Label { + visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || root.pollHandler.hasEnded + text: i18np("Based on votes by %1 user", "Based on votes by %1 users", root.pollHandler.answerCount) + (root.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") + font.pointSize: questionLabel.font.pointSize * 0.8 + } +} diff --git a/src/qml/PollDelegate.qml b/src/qml/PollDelegate.qml deleted file mode 100644 index 84a8a3f50..000000000 --- a/src/qml/PollDelegate.qml +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Tobias Fella -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Qt.labs.platform - -import org.kde.neochat - -/** - * @brief A timeline delegate for a poll message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The poll handler for this poll. - * - * This contains the required information like what the question, answers and - * current number of votes for each is. - */ - required property var pollHandler - - bubbleContent: ColumnLayout { - Label { - id: questionLabel - text: root.pollHandler.question - wrapMode: Text.Wrap - Layout.fillWidth: true - } - Repeater { - model: root.pollHandler.options - delegate: RowLayout { - Layout.fillWidth: true - CheckBox { - checked: root.pollHandler.answers[root.room.localUser.id] ? root.pollHandler.answers[root.room.localUser.id].includes(modelData["id"]) : false - onClicked: root.pollHandler.sendPollAnswer(root.eventId, modelData["id"]) - enabled: !root.pollHandler.hasEnded - } - Label { - text: modelData["org.matrix.msc1767.text"] - Layout.fillWidth: true - wrapMode: Text.Wrap - } - Label { - visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded - Layout.preferredWidth: contentWidth - text: root.pollHandler.counts[modelData["id"]] ?? "0" - } - } - } - Label { - visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || root.pollHandler.hasEnded - text: i18np("Based on votes by %1 user", "Based on votes by %1 users", root.pollHandler.answerCount) + (root.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") - font.pointSize: questionLabel.font.pointSize * 0.8 - } - } -} diff --git a/src/qml/ReplyComponent.qml b/src/qml/ReplyComponent.qml index bd948a881..da14a8a0e 100644 --- a/src/qml/ReplyComponent.qml +++ b/src/qml/ReplyComponent.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts +import Qt.labs.qmlmodels import org.kde.coreaddons import org.kde.kirigami as Kirigami @@ -23,6 +24,16 @@ import org.kde.neochat RowLayout { id: root + /** + * @brief The matrix ID of the reply event. + */ + required property var replyComponentType + + /** + * @brief The matrix ID of the reply event. + */ + required property var replyEventId + /** * @brief The reply author. * @@ -39,17 +50,12 @@ RowLayout { * * @sa Quotient::User */ - required property var author + required property var replyAuthor /** - * @brief The delegate type of the reply message. + * @brief The display text of the message replied to. */ - required property int type - - /** - * @brief The display text of the message. - */ - required property string display + required property string replyDisplay /** * @brief The media info for the reply event. @@ -66,15 +72,19 @@ RowLayout { * - height - The height in pixels of the audio media (image/video only). * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). */ - required property var mediaInfo + required property var replyMediaInfo - property real contentMaxWidth + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 /** * @brief The reply has been clicked. */ - signal replyClicked + signal replyClicked(string eventID) + implicitHeight: contentColumn.implicitHeight spacing: Kirigami.Units.largeSpacing Rectangle { @@ -82,12 +92,17 @@ RowLayout { Layout.fillHeight: true implicitWidth: Kirigami.Units.smallSpacing - color: root.author.color + color: root.replyAuthor.color } ColumnLayout { + id: contentColumn + implicitHeight: headerRow.implicitHeight + (root.replyComponentType != MessageComponentType.Other ? contentRepeater.itemAt(0).implicitHeight + spacing : 0) spacing: Kirigami.Units.smallSpacing RowLayout { + id: headerRow + implicitHeight: Math.max(replyAvatar.implicitHeight, replyName.implicitHeight) + Layout.maximumWidth: root.maxContentWidth spacing: Kirigami.Units.largeSpacing KirigamiComponents.Avatar { @@ -96,42 +111,87 @@ RowLayout { implicitWidth: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small - source: root.author.avatarSource - name: root.author.displayName - color: root.author.color + source: root.replyAuthor.avatarSource + name: root.replyAuthor.displayName + color: root.replyAuthor.color } QQC2.Label { id: replyName Layout.fillWidth: true - color: root.author.color - text: root.author.displayName + color: root.replyAuthor.color + text: root.replyAuthor.displayName elide: Text.ElideRight } } - Loader { - id: loader + Repeater { + id: contentRepeater + model: [root.replyComponentType] + delegate: DelegateChooser { + role: "modelData" - Layout.fillWidth: true - Layout.maximumHeight: loader.item && (root.type == DelegateType.Image || root.type == DelegateType.Sticker) ? loader.item.height : loader.item.implicitHeight - Layout.columnSpan: 2 + DelegateChoice { + roleValue: MessageComponentType.Text + delegate: TextComponent { + display: root.replyDisplay + maxContentWidth: _private.availableContentWidth - sourceComponent: { - switch (root.type) { - case DelegateType.Image: - case DelegateType.Sticker: - return imageComponent; - case DelegateType.Message: - case DelegateType.Notice: - return textComponent; - case DelegateType.File: - case DelegateType.Video: - case DelegateType.Audio: - return mimeComponent; - case DelegateType.Encrypted: - return encryptedComponent; - default: - return textComponent; + HoverHandler { + enabled: !hoveredLink + cursorShape: Qt.PointingHandCursor + } + TapHandler { + enabled: !hoveredLink + acceptedButtons: Qt.LeftButton + onTapped: root.replyClicked(root.replyEventId) + } + } + } + DelegateChoice { + roleValue: MessageComponentType.Image + delegate: Image { + id: image + Layout.maximumWidth: mediaSizeHelper.currentSize.width + Layout.maximumHeight: mediaSizeHelper.currentSize.height + source: root?.replyMediaInfo.source ?? "" + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: _private.availableContentWidth + mediaWidth: root?.replyMediaInfo.width ?? -1 + mediaHeight: root?.replyMediaInfo.height ?? -1 + } + } + } + DelegateChoice { + roleValue: MessageComponentType.File + delegate: MimeComponent { + mimeIconSource: root.replyMediaInfo.mimeIcon + label: root.replyDisplay + subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration) + } + } + DelegateChoice { + roleValue: MessageComponentType.Video + delegate: MimeComponent { + mimeIconSource: root.replyMediaInfo.mimeIcon + label: root.replyDisplay + subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration) + } + } + DelegateChoice { + roleValue: MessageComponentType.Audio + delegate: MimeComponent { + mimeIconSource: root.replyMediaInfo.mimeIcon + label: root.replyDisplay + subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration) + } + } + DelegateChoice { + roleValue: MessageComponentType.Encrypted + delegate: TextComponent { + display: i18n("This message is encrypted and the sender has not shared the key with this device.") + } } } } @@ -141,55 +201,11 @@ RowLayout { } TapHandler { acceptedButtons: Qt.LeftButton - onTapped: root.replyClicked() + onTapped: root.replyClicked(root.replyEventId) } - - Component { - id: textComponent - RichLabel { - textMessage: root.display - - HoverHandler { - enabled: !hoveredLink - cursorShape: Qt.PointingHandCursor - } - TapHandler { - enabled: !hoveredLink - acceptedButtons: Qt.LeftButton - onTapped: root.replyClicked() - } - } - } - Component { - id: imageComponent - Image { - id: image - width: mediaSizeHelper.currentSize.width - height: mediaSizeHelper.currentSize.height - fillMode: Image.PreserveAspectFit - source: root?.mediaInfo.source ?? "" - - MediaSizeHelper { - id: mediaSizeHelper - contentMaxWidth: root.contentMaxWidth - verticalBorder.width - root.spacing - mediaWidth: root?.mediaInfo.width ?? -1 - mediaHeight: root?.mediaInfo.height ?? -1 - } - } - } - Component { - id: mimeComponent - MimeComponent { - mimeIconSource: root.mediaInfo.mimeIcon - label: root.display - subLabel: root.type === DelegateType.File ? Format.formatByteSize(root.mediaInfo.size) : Format.formatDuration(root.mediaInfo.duration) - } - } - Component { - id: encryptedComponent - RichLabel { - textMessage: i18n("This message is encrypted and the sender has not shared the key with this device.") - textFormat: Text.RichText - } + QtObject { + id: _private + // The space available for the component after taking away the border + readonly property real availableContentWidth: root.maxContentWidth - verticalBorder.implicitWidth - root.spacing } } diff --git a/src/qml/RoomMedia.qml b/src/qml/RoomMedia.qml index 36cad157d..7a54e1e15 100644 --- a/src/qml/RoomMedia.qml +++ b/src/qml/RoomMedia.qml @@ -47,8 +47,8 @@ QQC2.ScrollView { role: "type" DelegateChoice { - roleValue: 0//MediaMessageFilterModel.Image - delegate: ImageDelegate { + roleValue: MediaMessageFilterModel.Image + delegate: MessageDelegate { alwaysShowAuthor: true alwaysMaxWidth: true cardBackground: false @@ -57,8 +57,8 @@ QQC2.ScrollView { } DelegateChoice { - roleValue: 1//MediaMessageFilterModel.Video - delegate: VideoDelegate { + roleValue: MediaMessageFilterModel.Video + delegate: MessageDelegate { alwaysShowAuthor: true alwaysMaxWidth: true cardBackground: false diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index 1231f462a..dcec134ad 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -256,23 +256,23 @@ Kirigami.Page { }); } - function onShowMessageMenu(eventId, author, delegateType, plainText, htmlText, selectedText) { + function onShowMessageMenu(eventId, author, messageComponentType, plainText, htmlText, selectedText) { const contextMenu = messageDelegateContextMenu.createObject(root, { selectedText: selectedText, author: author, eventId: eventId, - delegateType: delegateType, + messageComponentType: messageComponentType, plainText: plainText, htmlText: htmlText }); contextMenu.open(); } - function onShowFileMenu(eventId, author, delegateType, plainText, mimeType, progressInfo) { + function onShowFileMenu(eventId, author, messageComponentType, plainText, mimeType, progressInfo) { const contextMenu = fileDelegateContextMenu.createObject(root, { author: author, eventId: eventId, - delegateType: delegateType, + messageComponentType: messageComponentType, plainText: plainText, mimeType: mimeType, progressInfo: progressInfo diff --git a/src/qml/RichLabel.qml b/src/qml/TextComponent.qml similarity index 68% rename from src/qml/RichLabel.qml rename to src/qml/TextComponent.qml index 54b1f02e0..4fdbb7498 100644 --- a/src/qml/RichLabel.qml +++ b/src/qml/TextComponent.qml @@ -1,27 +1,29 @@ // SPDX-FileCopyrightText: 2020 Black Hat +// SPDX-FileCopyrightText: 2024 James Graham // SPDX-License-Identifier: GPL-3.0-only import QtQuick import QtQuick.Layouts -import org.kde.neochat import org.kde.kirigami as Kirigami +import org.kde.neochat + /** - * @brief A component to show the rich display text of text message. + * @brief A component to show rich text from a message. */ TextEdit { id: root /** - * @brief The rich text message to display. + * @brief The display text of the message. */ - property string textMessage + required property string display /** * @brief Whether this message is replying to another. */ - property bool isReply + property bool isReply: false /** * @brief Regex for detecting a message with a single emoji. @@ -31,7 +33,7 @@ TextEdit { /** * @brief Whether the message is an emoji */ - readonly property var isEmoji: isEmojiRegex.test(textMessage) + readonly property var isEmoji: isEmojiRegex.test(display) /** * @brief Regex for detecting a message with a spoiler. @@ -41,9 +43,23 @@ TextEdit { /** * @brief Whether a spoiler should be revealed. */ - property bool spoilerRevealed: !hasSpoiler.test(textMessage) + property bool spoilerRevealed: !hasSpoiler.test(display) - ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage)) + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + /** + * @brief Request a context menu be show for the message. + */ + signal showMessageMenu() + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.maximumWidth: root.maxContentWidth + + ListView.onReused: Qt.binding(() => !hasSpoiler.test(display)) persistentSelection: true @@ -91,7 +107,7 @@ a{ background: " + Kirigami.Theme.textColor + "; } " : "") + " -" + textMessage +" + display color: Kirigami.Theme.textColor selectedTextColor: Kirigami.Theme.highlightedTextColor @@ -106,8 +122,8 @@ a{ textFormat: Text.RichText onLinkActivated: link => { - spoilerRevealed = true; - RoomManager.resolveResource(link, "join"); + spoilerRevealed = true + RoomManager.resolveResource(link, "join") } onHoveredLinkChanged: if (hoveredLink.length > 0 && hoveredLink !== "1") { applicationWindow().hoverLinkIndicator.text = hoveredLink; @@ -116,11 +132,16 @@ a{ } HoverHandler { - cursorShape: (parent.hoveredLink || !spoilerRevealed) ? Qt.PointingHandCursor : Qt.IBeamCursor + cursorShape: (root.hoveredLink || !spoilerRevealed) ? Qt.PointingHandCursor : Qt.IBeamCursor } TapHandler { - enabled: !parent.hoveredLink && !spoilerRevealed + enabled: !root.hoveredLink && !spoilerRevealed onTapped: spoilerRevealed = true } + TapHandler { + enabled: !root.hoveredLink + acceptedButtons: Qt.LeftButton + onLongPressed: root.showMessageMenu() + } } diff --git a/src/qml/TextDelegate.qml b/src/qml/TextDelegate.qml deleted file mode 100644 index 29553b512..000000000 --- a/src/qml/TextDelegate.qml +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Tobias Fella -// SPDX-License-Identifier: GPL-3.0-only - -import QtQuick -import QtQuick.Layouts - -import Qt.labs.qmlmodels - -import org.kde.neochat -import org.kde.neochat.config - -/** - * @brief A timeline delegate for a text message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The link preview properties. - * - * This is a list or object containing the following: - * - url - The URL being previewed. - * - loaded - Whether the URL preview has been loaded. - * - title - the title of the URL preview. - * - description - the description of the URL preview. - * - imageSource - a source URL for the preview image. - * - * @note An empty link previewer should be passed if there are no links to - * preview. - */ - required property var linkPreview - - /** - * @brief Whether there are any links to preview. - */ - required property bool showLinkPreview - - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, display, label.selectedText) - - bubbleContent: ColumnLayout { - RichLabel { - id: label - Layout.fillWidth: true - visible: root.room.editCache.editId !== root.eventId - - isReply: root.isReply - - textMessage: root.display - - TapHandler { - enabled: !label.hoveredLink - acceptedButtons: Qt.LeftButton - onLongPressed: root.openContextMenu() - } - } - Loader { - Layout.fillWidth: true - Layout.minimumHeight: item ? item.minimumHeight : -1 - Layout.preferredWidth: item ? item.preferredWidth : -1 - visible: root.room.editCache.editId === root.eventId - active: visible - sourceComponent: MessageEditComponent { - room: root.room - actionsHandler: root.ListView.view.actionsHandler - messageId: root.eventId - } - } - LinkPreviewDelegate { - Layout.fillWidth: true - active: !root.room.usesEncryption && root.room.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview && !root.linkPreview.empty - linkPreviewer: root.linkPreview - indicatorEnabled: root.isVisibleInTimeline() - } - } -} diff --git a/src/qml/VideoComponent.qml b/src/qml/VideoComponent.qml new file mode 100644 index 000000000..c6cc4a095 --- /dev/null +++ b/src/qml/VideoComponent.qml @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: 2019 Black Hat +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-3.0-only + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtMultimedia +import Qt.labs.platform as Platform + +import org.kde.coreaddons +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +/** + * @brief A component to show a video from a message. + */ +Video { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be image/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be image-xxx). + * - size - The file size in bytes. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + + /** + * @brief FileTransferInfo for any downloading files. + */ + required property var fileTransferInfo + + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed + onDownloadedChanged: { + if (downloaded) { + root.source = root.fileTransferInfo.localPath + } + + if (downloaded && playOnFinished) { + playSavedFile() + playOnFinished = false + } + } + + /** + * @brief Whether the video should be played when downloaded. + */ + property bool playOnFinished: false + + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + + /** + * @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 + + fillMode: VideoOutput.PreserveAspectFit + + states: [ + State { + name: "notDownloaded" + when: !root.fileTransferInfo.completed && !root.fileTransferInfo.active + PropertyChanges { + target: noDownloadLabel + visible: true + } + PropertyChanges { + target: mediaThumbnail + visible: true + } + }, + State { + name: "downloading" + when: root.fileTransferInfo.active && !root.fileTransferInfo.completed + PropertyChanges { + target: downloadBar + visible: true + } + }, + State { + name: "paused" + when: root.fileTransferInfo.completed && (root.playbackState === MediaPlayer.StoppedState || root.playbackState === MediaPlayer.PausedState) + PropertyChanges { + target: videoControls + stateVisible: true + } + PropertyChanges { + target: playButton + icon.name: "media-playback-start" + onClicked: root.play() + } + }, + State { + name: "playing" + when: root.fileTransferInfo.completed && root.playbackState === MediaPlayer.PlayingState + PropertyChanges { + target: videoControls + stateVisible: true + } + PropertyChanges { + target: playButton + icon.name: "media-playback-pause" + onClicked: root.pause() + } + } + ] + + Image { + id: mediaThumbnail + anchors.fill: parent + visible: false + + source: root.mediaInfo.tempInfo.source + fillMode: Image.PreserveAspectFit + } + + QQC2.Label { + id: noDownloadLabel + anchors.centerIn: parent + + visible: false + color: "white" + text: i18n("Video") + font.pixelSize: 16 + + padding: 8 + + background: Rectangle { + radius: Kirigami.Units.smallSpacing + color: "black" + opacity: 0.3 + } + } + + Rectangle { + id: downloadBar + anchors.fill: parent + visible: false + + color: Kirigami.Theme.backgroundColor + radius: Kirigami.Units.smallSpacing + + QQC2.ProgressBar { + anchors.centerIn: parent + + width: parent.width * 0.8 + + from: 0 + to: root.fileTransferInfo.total + value: root.fileTransferInfo.progress + } + } + + QQC2.Control { + id: videoControls + property bool stateVisible: false + + anchors.bottom: root.bottom + anchors.left: root.left + anchors.right: root.right + visible: stateVisible && (videoHoverHandler.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || videoControlTimer.running) + + contentItem: RowLayout { + id: controlRow + QQC2.ToolButton { + id: playButton + } + QQC2.Slider { + Layout.fillWidth: true + from: 0 + to: root.duration + value: root.position + onMoved: root.seek(value) + } + QQC2.Label { + text: Format.formatDuration(root.position) + "/" + Format.formatDuration(root.duration) + } + QQC2.ToolButton { + id: volumeButton + property var unmuteVolume: root.volume + + icon.name: root.volume <= 0 ? "player-volume-muted" : "player-volume" + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.timeout: Kirigami.Units.toolTipDelay + QQC2.ToolTip.text: i18nc("@action:button", "Volume") + + onClicked: { + if (root.volume > 0) { + root.volume = 0 + } else { + if (unmuteVolume === 0) { + root.volume = 1 + } else { + root.volume = unmuteVolume + } + } + } + onHoveredChanged: { + if (!hovered && (root.state === "paused" || root.state === "playing")) { + videoControlTimer.restart() + volumePopupTimer.restart() + } + } + + QQC2.Popup { + id: volumePopup + y: -height + width: volumeButton.width + visible: videoControls.stateVisible && (volumeButton.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || volumePopupTimer.running) + + focus: true + padding: Kirigami.Units.smallSpacing + closePolicy: QQC2.Popup.NoAutoClose + + QQC2.Slider { + id: volumeSlider + anchors.centerIn: parent + implicitHeight: Kirigami.Units.gridUnit * 7 + orientation: Qt.Vertical + padding: 0 + from: 0 + to: 1 + value: root.volume + onMoved: { + root.volume = value + volumeButton.unmuteVolume = value + } + onHoveredChanged: { + if (!hovered && (root.state === "paused" || root.state === "playing")) { + rooteoControlTimer.restart() + volumePopupTimer.restart() + } + } + } + Timer { + id: volumePopupTimer + interval: 500 + } + HoverHandler { + id: volumePopupHoverHandler + onHoveredChanged: { + if (!hovered && (root.state === "paused" || root.state === "playing")) { + videoControlTimer.restart() + volumePopupTimer.restart() + } + } + } + background: Kirigami.ShadowedRectangle { + radius: 4 + color: Kirigami.Theme.backgroundColor + opacity: 0.8 + + property color borderColor: Kirigami.Theme.textColor + border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3) + border.width: 1 + + shadow.xOffset: 0 + shadow.yOffset: 4 + shadow.color: Qt.rgba(0, 0, 0, 0.3) + shadow.size: 8 + } + } + } + QQC2.ToolButton { + id: maximizeButton + display: QQC2.AbstractButton.IconOnly + + action: Kirigami.Action { + text: i18n("Maximize") + icon.name: "view-fullscreen" + onTriggered: { + root.timeline.interactive = false + root.pause() + // We need to make sure the index is that of the MediaMessageFilterModel. + if (root.timeline.model instanceof MessageFilterModel) { + RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)) + } else { + RoomManager.maximizeMedia(root.index) + } + } + } + } + } + background: Kirigami.ShadowedRectangle { + radius: 4 + color: Kirigami.Theme.backgroundColor + opacity: 0.8 + + property color borderColor: Kirigami.Theme.textColor + border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3) + border.width: 1 + + shadow.xOffset: 0 + shadow.yOffset: 4 + shadow.color: Qt.rgba(0, 0, 0, 0.3) + shadow.size: 8 + } + } + + Timer { + id: videoControlTimer + interval: 1000 + } + HoverHandler { + id: videoHoverHandler + onHoveredChanged: { + if (!hovered && (root.state === "paused" || root.state === "playing")) { + videoControlTimer.restart() + } + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds + onTapped: if (root.fileTransferInfo.completed) { + if (root.playbackState == MediaPlayer.PlayingState) { + root.pause() + } else { + root.play() + } + } else { + root.downloadAndPlay() + } + } + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.maxContentWidth + mediaWidth: root.mediaInfo.width + mediaHeight: root.mediaInfo.height + } + + function downloadAndPlay() { + if (root.downloaded) { + playSavedFile() + } else { + playOnFinished = true + root.room.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId)) + } + } + + function playSavedFile() { + root.stop() + root.play() + } +} diff --git a/src/qml/VideoDelegate.qml b/src/qml/VideoDelegate.qml deleted file mode 100644 index 4bcd409d0..000000000 --- a/src/qml/VideoDelegate.qml +++ /dev/null @@ -1,368 +0,0 @@ -// SPDX-FileCopyrightText: 2019 Black Hat -// SPDX-License-Identifier: GPL-3.0-only - -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Layouts -import QtMultimedia -import Qt.labs.platform as Platform - -import org.kde.coreaddons -import org.kde.kirigami as Kirigami - -import org.kde.neochat - -/** - * @brief A timeline delegate for a video message. - * - * @inherit MessageDelegate - */ -MessageDelegate { - id: root - - /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be video/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be video-xxx). - * - size - The file size in bytes. - * - duration - The length in seconds of the audio media. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. - */ - required property var mediaInfo - - /** - * @brief Whether the media has been downloaded. - */ - readonly property bool downloaded: root.progressInfo && root.progressInfo.completed - - /** - * @brief Whether the video should be played when downloaded. - */ - property bool playOnFinished: false - - /** - * @brief The maximum width of the image. - */ - readonly property var maxWidth: Kirigami.Units.gridUnit * 30 - - /** - * @brief The maximum height of the image. - */ - readonly property var maxHeight: Kirigami.Units.gridUnit * 30 - - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) - - onDownloadedChanged: { - if (downloaded) { - vid.source = root.progressInfo.localPath; - } - if (downloaded && playOnFinished) { - playSavedFile(); - playOnFinished = false; - } - } - - bubbleContent: Video { - id: vid - implicitWidth: mediaSizeHelper.currentSize.width - implicitHeight: mediaSizeHelper.currentSize.height - - fillMode: VideoOutput.PreserveAspectFit - - states: [ - State { - name: "notDownloaded" - when: !root.progressInfo.completed && !root.progressInfo.active - PropertyChanges { - target: noDownloadLabel - visible: true - } - PropertyChanges { - target: mediaThumbnail - visible: true - } - }, - State { - name: "downloading" - when: root.progressInfo.active && !root.progressInfo.completed - PropertyChanges { - target: downloadBar - visible: true - } - }, - State { - name: "paused" - when: root.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState) - PropertyChanges { - target: videoControls - stateVisible: true - } - PropertyChanges { - target: playButton - icon.name: "media-playback-start" - onClicked: vid.play() - } - }, - State { - name: "playing" - when: root.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState - PropertyChanges { - target: videoControls - stateVisible: true - } - PropertyChanges { - target: playButton - icon.name: "media-playback-pause" - onClicked: vid.pause() - } - } - ] - - Image { - id: mediaThumbnail - anchors.fill: parent - visible: false - - source: root.mediaInfo.tempInfo.source - fillMode: Image.PreserveAspectFit - } - - QQC2.Label { - id: noDownloadLabel - anchors.centerIn: parent - - visible: false - color: "white" - text: i18n("Video") - font.pixelSize: 16 - - padding: 8 - - background: Rectangle { - radius: Kirigami.Units.smallSpacing - color: "black" - opacity: 0.3 - } - } - - Rectangle { - id: downloadBar - anchors.fill: parent - visible: false - - color: Kirigami.Theme.backgroundColor - radius: Kirigami.Units.smallSpacing - - QQC2.ProgressBar { - anchors.centerIn: parent - - width: parent.width * 0.8 - - from: 0 - to: root.progressInfo.total - value: root.progressInfo.progress - } - } - - QQC2.Control { - id: videoControls - property bool stateVisible: false - - anchors.bottom: vid.bottom - anchors.left: vid.left - anchors.right: vid.right - visible: stateVisible && (videoHoverHandler.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || videoControlTimer.running) - - contentItem: RowLayout { - id: controlRow - QQC2.ToolButton { - id: playButton - } - QQC2.Slider { - Layout.fillWidth: true - from: 0 - to: vid.duration - value: vid.position - onMoved: vid.seek(value) - } - QQC2.Label { - text: Format.formatDuration(vid.position) + "/" + Format.formatDuration(vid.duration) - } - QQC2.ToolButton { - id: volumeButton - property var unmuteVolume: vid.volume - - icon.name: vid.volume <= 0 ? "player-volume-muted" : "player-volume" - - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - QQC2.ToolTip.timeout: Kirigami.Units.toolTipDelay - QQC2.ToolTip.text: i18nc("@action:button", "Volume") - - onClicked: { - if (vid.volume > 0) { - vid.volume = 0; - } else { - if (unmuteVolume === 0) { - vid.volume = 1; - } else { - vid.volume = unmuteVolume; - } - } - } - onHoveredChanged: { - if (!hovered && (vid.state === "paused" || vid.state === "playing")) { - videoControlTimer.restart(); - volumePopupTimer.restart(); - } - } - - QQC2.Popup { - id: volumePopup - y: -height - width: volumeButton.width - visible: videoControls.stateVisible && (volumeButton.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || volumePopupTimer.running) - - focus: true - padding: Kirigami.Units.smallSpacing - closePolicy: QQC2.Popup.NoAutoClose - - QQC2.Slider { - id: volumeSlider - anchors.centerIn: parent - implicitHeight: Kirigami.Units.gridUnit * 7 - orientation: Qt.Vertical - padding: 0 - from: 0 - to: 1 - value: vid.volume - onMoved: { - vid.volume = value; - volumeButton.unmuteVolume = value; - } - onHoveredChanged: { - if (!hovered && (vid.state === "paused" || vid.state === "playing")) { - videoControlTimer.restart(); - volumePopupTimer.restart(); - } - } - } - Timer { - id: volumePopupTimer - interval: 500 - } - HoverHandler { - id: volumePopupHoverHandler - onHoveredChanged: { - if (!hovered && (vid.state === "paused" || vid.state === "playing")) { - videoControlTimer.restart(); - volumePopupTimer.restart(); - } - } - } - background: Kirigami.ShadowedRectangle { - radius: 4 - color: Kirigami.Theme.backgroundColor - opacity: 0.8 - - property color borderColor: Kirigami.Theme.textColor - border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3) - border.width: 1 - - shadow.xOffset: 0 - shadow.yOffset: 4 - shadow.color: Qt.rgba(0, 0, 0, 0.3) - shadow.size: 8 - } - } - } - QQC2.ToolButton { - id: maximizeButton - display: QQC2.AbstractButton.IconOnly - - action: Kirigami.Action { - text: i18n("Maximize") - icon.name: "view-fullscreen" - onTriggered: { - root.ListView.view.interactive = false; - vid.pause(); - // We need to make sure the index is that of the MediaMessageFilterModel. - if (root.ListView.view.model instanceof MessageFilterModel) { - RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)); - } else { - RoomManager.maximizeMedia(root.index); - } - } - } - } - } - background: Kirigami.ShadowedRectangle { - radius: 4 - color: Kirigami.Theme.backgroundColor - opacity: 0.8 - - property color borderColor: Kirigami.Theme.textColor - border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3) - border.width: 1 - - shadow.xOffset: 0 - shadow.yOffset: 4 - shadow.color: Qt.rgba(0, 0, 0, 0.3) - shadow.size: 8 - } - } - - Timer { - id: videoControlTimer - interval: 1000 - } - HoverHandler { - id: videoHoverHandler - onHoveredChanged: { - if (!hovered && (vid.state === "paused" || vid.state === "playing")) { - videoControlTimer.restart(); - } - } - } - - TapHandler { - acceptedButtons: Qt.LeftButton - gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds - onTapped: if (root.progressInfo.completed) { - if (vid.playbackState == MediaPlayer.PlayingState) { - vid.pause(); - } else { - vid.play(); - } - } else { - root.downloadAndPlay(); - } - } - - MediaSizeHelper { - id: mediaSizeHelper - contentMaxWidth: root.contentMaxWidth - mediaWidth: root.mediaInfo.width - mediaHeight: root.mediaInfo.height - } - } - - function downloadAndPlay() { - if (vid.downloaded) { - playSavedFile(); - } else { - playOnFinished = true; - root.room.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId)); - } - } - - function playSavedFile() { - vid.stop(); - vid.play(); - } -} diff --git a/src/roommanager.cpp b/src/roommanager.cpp index 813525d41..aa3e3c3ae 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -5,7 +5,8 @@ #include "roommanager.h" #include "chatbarcache.h" -#include "enums/delegatetype.h" +#include "eventhandler.h" +#include "messagecomponenttype.h" #include "models/timelinemodel.h" #include "neochatconfig.h" #include "neochatconnection.h" @@ -127,22 +128,27 @@ void RoomManager::viewEventSource(const QString &eventId) Q_EMIT showEventSource(eventId); } -void RoomManager::viewEventMenu(const QString &eventId, - const QVariantMap &author, - DelegateType::Type delegateType, - const QString &plainText, - const QString &htmlText, - const QString &selectedText, - const QString &mimeType, - const FileTransferInfo &progressInfo) +void RoomManager::viewEventMenu(const QString &eventId, NeoChatRoom *room, const QString &selectedText) { - if (delegateType == DelegateType::Image || delegateType == DelegateType::Video || delegateType == DelegateType::Audio - || delegateType == DelegateType::File) { - Q_EMIT showFileMenu(eventId, author, delegateType, plainText, mimeType, progressInfo); + const auto &event = **room->findInTimeline(eventId); + const auto eventHandler = EventHandler(room, &event); + + if (eventHandler.getMediaInfo().contains("mimeType"_ls)) { + Q_EMIT showFileMenu(eventId, + eventHandler.getAuthor(), + eventHandler.messageComponentType(), + eventHandler.getPlainBody(), + eventHandler.getMediaInfo()["mimeType"_ls].toString(), + room->fileTransferInfo(eventId)); return; } - Q_EMIT showMessageMenu(eventId, author, delegateType, plainText, htmlText, selectedText); + Q_EMIT showMessageMenu(eventId, + eventHandler.getAuthor(), + eventHandler.messageComponentType(), + eventHandler.getPlainBody(), + eventHandler.getRichBody(), + selectedText); } bool RoomManager::hasOpenRoom() const diff --git a/src/roommanager.h b/src/roommanager.h index 67d82feb2..1d49f67ae 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -12,7 +12,8 @@ #include #include "chatdocumenthandler.h" -#include "enums/delegatetype.h" +#include "enums/messagecomponenttype.h" +#include "eventhandler.h" #include "models/mediamessagefiltermodel.h" #include "models/messagefiltermodel.h" #include "models/timelinemodel.h" @@ -182,16 +183,9 @@ public: Q_INVOKABLE void viewEventSource(const QString &eventId); /** - * @brief Show a conterxt menu for the given event. + * @brief Show a context menu for the given event. */ - Q_INVOKABLE void viewEventMenu(const QString &eventId, - const QVariantMap &author, - DelegateType::Type delegateType, - const QString &plainText, - const QString &htmlText = {}, - const QString &selectedText = {}, - const QString &mimeType = {}, - const FileTransferInfo &progressInfo = {}); + Q_INVOKABLE void viewEventMenu(const QString &eventId, NeoChatRoom *room, const QString &selectedText = {}); /** * @brief Call this when the current used connection is dropped. @@ -299,7 +293,7 @@ Q_SIGNALS: */ void showMessageMenu(const QString &eventId, const QVariantMap &author, - DelegateType::Type delegateType, + MessageComponentType::Type messageComponentType, const QString &plainText, const QString &htmlText, const QString &selectedText); @@ -309,7 +303,7 @@ Q_SIGNALS: */ void showFileMenu(const QString &eventId, const QVariantMap &author, - DelegateType::Type delegateType, + MessageComponentType::Type messageComponentType, const QString &plainText, const QString &mimeType, const FileTransferInfo &progressInfo);