Message Content Rework

For now everything should look identical. However this moves to using a model for the content of the message and is intended to lay the foundation for improved message content representation, e.g. splitting up a text message in multiple sections and using different delegates for things like code and quotes.
This commit is contained in:
James Graham
2024-02-18 09:53:08 +00:00
parent 0ebcacce69
commit b598584aea
52 changed files with 2515 additions and 2186 deletions

View File

@@ -12,7 +12,6 @@
#include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h>
#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<int>("eventNum");
QTest::addColumn<DelegateType::Type>("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();

View File

@@ -103,7 +103,6 @@ void MessageEventModelTest::simpleTimeline()
QCOMPARE(model->data(model->index(1)), QStringLiteral("<b>This is an example<br>text message</b>"));
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)");

View File

@@ -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

View File

@@ -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<Mention> *ChatBarCache::mentions()

View File

@@ -186,7 +186,7 @@ public:
Q_SIGNALS:
void textChanged();
void relationIdChanged();
void relationIdChanged(const QString &oldEventId, const QString &newEventId);
void threadIdChanged();
void attachmentPathChanged();

View File

@@ -6,6 +6,13 @@
#include <QObject>
#include <QQmlEngine>
#include <Quotient/events/encryptedevent.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#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<Quotient::RoomMessageEvent>() || event.is<Quotient::StickerEvent>() || event.is<Quotient::EncryptedEvent>()
|| event.is<Quotient::PollStartEvent>()) {
return Message;
}
if (event.isStateEvent()) {
if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return Message;
}
return State;
}
return Other;
}
};

View File

@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <Quotient/events/encryptedevent.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#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<const RoomMessageEvent>(&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<const StickerEvent>(event)) {
return MessageComponentType::Image;
}
if (event.isStateEvent()) {
if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return MessageComponentType::LiveLocation;
}
return MessageComponentType::Other;
}
if (is<const EncryptedEvent>(event)) {
return MessageComponentType::Encrypted;
}
if (is<PollStartEvent>(event)) {
const auto pollEvent = eventCast<const PollStartEvent>(&event);
if (pollEvent->isRedacted()) {
return MessageComponentType::Text;
}
return MessageComponentType::Poll;
}
return MessageComponentType::Other;
}
};

View File

@@ -14,18 +14,19 @@
#include <Quotient/events/roomavatarevent.h>
#include <Quotient/events/roomcanonicalaliasevent.h>
#include <Quotient/events/roommemberevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/roompowerlevelsevent.h>
#include <Quotient/events/simplestateevents.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/quotient_common.h>
#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<const RoomMessageEvent>(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<const StickerEvent>(*event)) {
return DelegateType::Sticker;
}
if (event->isStateEvent()) {
if (event->matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return DelegateType::LiveLocation;
}
return DelegateType::State;
}
if (is<const EncryptedEvent>(*event)) {
return DelegateType::Encrypted;
}
if (is<PollStartEvent>(*event)) {
const auto pollEvent = eventCast<const PollStartEvent>(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

View File

@@ -11,7 +11,7 @@
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommessageevent.h>
#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;

View File

@@ -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)

View File

@@ -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;

View File

@@ -3,9 +3,9 @@
#include "mediamessagefiltermodel.h"
#include <Quotient/events/roommessageevent.h>
#include <Quotient/room.h>
#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<Quotient::FileTransferInfo>();
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;

View File

@@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "messagecontentmodel.h"
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/stickerevent.h>
#include <KLocalizedString>
#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<const Quotient::RoomMessageEvent>(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("<i>[This message was deleted]</i>")
: i18n("<i>[This message was deleted: %1]</i>", 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<const Quotient::RoomMessageEvent>(m_event)) {
if (event->hasFileContent()) {
return QVariant::fromValue(m_room->fileTransferInfo(event->id()));
}
}
if (auto event = eventCast<const Quotient::StickerEvent>(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<PollHandler *>(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<LinkPreviewer *>(m_linkPreviewer);
} else {
return QVariant::fromValue<LinkPreviewer *>(emptyLinkPreview);
}
}
return {};
}
int MessageContentModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_components.size();
}
QHash<int, QByteArray> MessageContentModel::roleNames() const
{
QHash<int, QByteArray> 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();
}

View File

@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#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<int, QByteArray> roleNames() const override;
private:
NeoChatRoom *m_room = nullptr;
const Quotient::RoomEvent *m_event = nullptr;
QVector<MessageComponentType::Type> m_components;
void updateComponents();
LinkPreviewer *m_linkPreviewer = nullptr;
};

View File

@@ -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 <Quotient/connection.h>
#include <Quotient/csapi/rooms.h>
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/user.h>
@@ -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<int, QByteArray> MessageEventModel::roleNames() const
{
QHash<int, QByteArray> roles = QAbstractItemModel::roleNames();
roles[DelegateTypeRole] = "delegateType";
roles[PlainText] = "plainText";
roles[EventIdRole] = "eventId";
roles[TimeRole] = "time";
roles[TimeStringRole] = "timeString";
@@ -40,15 +41,6 @@ QHash<int, QByteArray> 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<int, QByteArray> 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<const RoomMessageEvent>(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<MessageContentModel *>(new MessageContentModel(&evt, m_currentRoom));
}
if (evt.isStateEvent()) {
if (evt.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return QVariant::fromValue<MessageContentModel *>(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<LinkPreviewer *>(m_linkPreviewers[evt.id()].data());
} else {
return QVariant::fromValue<LinkPreviewer *>(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<int>(m_currentRoom->pendingEvents().size());
}
if (role == PollHandlerRole) {
return QVariant::fromValue<PollHandler *>(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<LinkPreviewer>(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);
}

View File

@@ -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<QString, QSharedPointer<LinkPreviewer>> m_linkPreviewers;
QMap<QString, QSharedPointer<ReactionModel>> m_reactionModels;
[[nodiscard]] int timelineBaseIndex() const;

View File

@@ -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 <QGuiApplication>
@@ -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<MessageContentModel *>(new MessageContentModel(&event, m_room));
}
if (event.isStateEvent()) {
if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(&event, m_room));
}
}
return {};
}
}
return DelegateType::Message;
}
@@ -144,7 +140,6 @@ QHash<int, QByteArray> SearchModel::roleNames() const
{
return {
{DelegateTypeRole, "delegateType"},
{DisplayRole, "display"},
{AuthorRole, "author"},
{ShowSectionRole, "showSection"},
{SectionRole, "section"},
@@ -155,25 +150,15 @@ QHash<int, QByteArray> 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

View File

@@ -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);

168
src/qml/AudioComponent.qml Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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)
}
}

View File

@@ -1,144 +0,0 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// 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)
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// 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
}

View File

@@ -1,31 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// 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
}
}

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 {}

302
src/qml/FileComponent.qml Normal file
View File

@@ -0,0 +1,302 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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("<start time> - <end time>", "%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
}
}

View File

@@ -1,277 +0,0 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// 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("<start time> - <end time>", "%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
}
}
}

174
src/qml/ImageComponent.qml Normal file
View File

@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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
}
}

View File

@@ -1,169 +0,0 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// 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
}
}
}

View File

@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-FileCopyrightText: 2023-2024 James Graham <james.h.graham@protonmail.com>
// 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: "<style>
a {
text-decoration: none;
}
</style>
<a href=\"" + root.linkPreviewer.url + "\">" + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>"
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
}
}
}

View File

@@ -1,148 +0,0 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// 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: "<style>
a {
text-decoration: none;
}
</style>
<a href=\"" + root.linkPreviewer.url + "\">" + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>"
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")
}
}
}
}

View File

@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2023 Volker Krause <vkrause@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 !== ""
}
}

View File

@@ -1,73 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2023 Volker Krause <vkrause@kde.org>
// 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 !== ""
}
}
}

60
src/qml/LoadComponent.qml Normal file
View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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");
}
}
}
}

View File

@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 !== ""
}
}

View File

@@ -1,92 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// 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 !== ""
}
}
}

View File

@@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 {}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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);

76
src/qml/PollComponent.qml Normal file
View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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
}
}

View File

@@ -1,61 +0,0 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// 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
}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,27 +1,29 @@
// SPDX-FileCopyrightText: 2020 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 + ";
}
" : "") + "
</style>" + textMessage
</style>" + 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()
}
}

View File

@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// 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()
}
}
}

381
src/qml/VideoComponent.qml Normal file
View File

@@ -0,0 +1,381 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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()
}
}

View File

@@ -1,368 +0,0 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// 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();
}
}

View File

@@ -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

View File

@@ -12,7 +12,8 @@
#include <KConfigGroup>
#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);