Files
neochat/src/messagecontent/models/eventmessagecontentmodel.cpp
Arno Rehn 1a43d15c6d EventHandler: Acknowledge that non-room-events can have a body as well
Specifically, in the current architecture, these can be EncryptedEvents
which are redactions of previous events. These will have artifical bodies.
2025-10-08 14:01:48 +00:00

510 lines
18 KiB
C++

// 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 "eventmessagecontentmodel.h"
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/qt_connection_util.h>
#include <Quotient/thread.h>
#include <KLocalizedString>
#include <Kirigami/Platform/PlatformTheme>
#include "chatbarcache.h"
#include "contentprovider.h"
#include "eventhandler.h"
#include "models/reactionmodel.h"
#include "neochatroom.h"
#include "texthandler.h"
using namespace Quotient;
bool EventMessageContentModel::m_threadsEnabled = false;
EventMessageContentModel::EventMessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply, bool isPending, MessageContentModel *parent)
: MessageContentModel(room, parent, eventId)
, m_currentState(isPending ? Pending : Unknown)
, m_isReply(isReply)
{
initializeModel();
}
void EventMessageContentModel::initializeModel()
{
Q_ASSERT(m_room != nullptr);
Q_ASSERT(!m_eventId.isEmpty());
connect(m_room, &NeoChatRoom::pendingEventAdded, this, [this]() {
if (m_room != nullptr && m_currentState == Unknown) {
initializeEvent();
resetModel();
}
});
connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) {
if (m_room != nullptr) {
if (m_eventId == serverEvent->id() || m_eventId == serverEvent->transactionId()) {
m_eventId = serverEvent->id();
}
}
});
connect(m_room, &NeoChatRoom::pendingEventMerged, this, [this]() {
if (m_room != nullptr && m_currentState == Pending) {
initializeEvent();
resetModel();
}
});
connect(m_room, &NeoChatRoom::addedMessages, this, [this](int fromIndex, int toIndex) {
if (!m_room) {
return;
}
for (int i = fromIndex; i <= toIndex; i++) {
if (m_room->findInTimeline(i)->event()->id() == m_eventId) {
initializeEvent();
resetModel();
}
}
});
connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) {
if (m_room != nullptr) {
if (m_eventId == newEvent->id()) {
initializeEvent();
resetContent();
}
}
});
connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) {
if (oldEventId == m_eventId || newEventId == m_eventId) {
resetContent(newEventId == m_eventId);
}
});
connect(m_room->threadCache(), &ChatBarCache::threadIdChanged, this, [this](const QString &oldThreadId, const QString &newThreadId) {
if (oldThreadId == m_eventId || newThreadId == m_eventId) {
resetContent(false, newThreadId == m_eventId);
}
});
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() {
resetContent();
});
connect(m_room, &Room::memberNameUpdated, this, [this](RoomMember member) {
if (m_room != nullptr) {
if (authorId().isEmpty() || authorId() == member.id()) {
Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole});
Q_EMIT authorChanged();
}
}
});
connect(m_room, &Room::memberAvatarUpdated, this, [this](RoomMember member) {
if (m_room != nullptr) {
if (authorId().isEmpty() || authorId() == member.id()) {
Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole});
Q_EMIT authorChanged();
}
}
});
connect(this, &EventMessageContentModel::threadsEnabledChanged, this, [this]() {
resetModel();
});
connect(m_room, &Room::updatedEvent, this, [this](const QString &eventId) {
if (eventId == m_eventId) {
updateReactionModel();
}
});
#if Quotient_VERSION_MINOR > 9
connect(m_room, &Room::newThread, this, [this](const Thread &newThread) {
if (newThread.threadRootId == m_eventId) {
resetContent();
}
});
#endif
initializeEvent();
resetModel();
}
QDateTime EventMessageContentModel::time() const
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return MessageContentModel::time();
};
return EventHandler::time(m_room, event.first, m_currentState == Pending);
}
QString EventMessageContentModel::timeString() const
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return MessageContentModel::timeString();
};
return EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending);
}
QString EventMessageContentModel::authorId() const
{
const auto eventResult = m_room->getEvent(m_eventId);
if (eventResult.first == nullptr) {
return {};
}
auto authorId = eventResult.first->senderId();
if (authorId.isEmpty()) {
return MessageContentModel::authorId();
}
return authorId;
}
QString EventMessageContentModel::threadRootId() const
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return {};
}
auto roomMessageEvent = eventCast<const RoomMessageEvent>(event.first);
if (roomMessageEvent && roomMessageEvent->isThreaded()) {
return roomMessageEvent->threadRootEventId();
} else if (m_room->threads().contains(roomMessageEvent->id())) {
return m_eventId;
}
return {};
}
void EventMessageContentModel::initializeEvent()
{
if (m_currentState == UnAvailable) {
return;
}
const auto eventResult = m_room->getEvent(m_eventId);
if (eventResult.first == nullptr) {
if (m_currentState != Pending) {
getEvent();
}
return;
}
if (eventResult.second) {
m_currentState = Pending;
} else {
m_currentState = Available;
}
Q_EMIT eventUpdated();
}
void EventMessageContentModel::getEvent()
{
Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventLoaded, this, [this](const QString &eventId) {
if (m_room != nullptr) {
if (eventId == m_eventId) {
initializeEvent();
resetModel();
return true;
}
}
return false;
});
Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventNotFound, this, [this](const QString &eventId) {
if (m_room != nullptr) {
if (eventId == m_eventId) {
m_currentState = UnAvailable;
resetModel();
return true;
}
}
return false;
});
m_room->downloadEventFromServer(m_eventId);
}
MessageComponent EventMessageContentModel::unavailableMessageComponent() const
{
const auto theme = static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
QString disabledTextColor;
if (theme != nullptr) {
disabledTextColor = theme->disabledTextColor().name();
} else {
disabledTextColor = u"#000000"_s;
}
return MessageComponent{
.type = MessageComponentType::Text,
.display = u"<span style=\"color:%1\">"_s.arg(disabledTextColor)
+ i18nc("@info", "This message was either not found, you do not have permission to view it, or it was sent by an ignored user") + u"</span>"_s,
.attributes = {},
};
}
void EventMessageContentModel::resetModel()
{
beginResetModel();
m_components.clear();
if (m_room->connection()->isIgnored(authorId()) || m_currentState == UnAvailable) {
m_components += unavailableMessageComponent();
endResetModel();
return;
}
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
m_components +=
MessageComponent{MessageComponentType::Loading, m_isReply ? i18nc("@info", "Loading reply…") : i18nc("@info Loading this message", "Loading…"), {}};
endResetModel();
return;
}
m_components += MessageComponent{MessageComponentType::Author,
QString(),
{
{u"time"_s, EventHandler::time(m_room, event.first, m_currentState == Pending)},
{u"timeString"_s, EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending)},
}};
m_components += messageContentComponents();
endResetModel();
updateReplyModel();
updateReactionModel();
updateItineraryModel();
Q_EMIT componentsUpdated();
}
void EventMessageContentModel::resetContent(bool isEditing, bool isThreading)
{
const auto startRow = m_components[0].type == MessageComponentType::Author ? 1 : 0;
beginRemoveRows({}, startRow, rowCount() - 1);
m_components.remove(startRow, rowCount() - startRow);
endRemoveRows();
const auto newComponents = messageContentComponents(isEditing, isThreading);
if (newComponents.size() == 0) {
return;
}
beginInsertRows({}, startRow, startRow + newComponents.size() - 1);
m_components += newComponents;
endInsertRows();
updateReplyModel();
updateReactionModel();
updateItineraryModel();
Q_EMIT componentsUpdated();
}
QList<MessageComponent> EventMessageContentModel::messageContentComponents(bool isEditing, bool isThreading)
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return {};
}
QList<MessageComponent> newComponents;
if (isEditing) {
newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}};
} else {
newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply)));
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
if (m_threadsEnabled && roomMessageEvent
&& ((roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId())
|| m_room->threads().contains(roomMessageEvent->id()))) {
newComponents += MessageComponent{MessageComponentType::Separator, {}, {}};
newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}};
}
// If the event is already threaded the ThreadModel will handle displaying a chat bar.
if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) {
newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}};
}
return newComponents;
}
void EventMessageContentModel::updateReplyModel()
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr || m_isReply) {
return;
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
if (roomMessageEvent == nullptr) {
return;
}
if (!roomMessageEvent->isReply(m_threadsEnabled) || (roomMessageEvent->isThreaded() && m_threadsEnabled)) {
if (m_replyModel) {
m_replyModel->disconnect(this);
m_replyModel->deleteLater();
}
return;
}
m_replyModel = new EventMessageContentModel(m_room, roomMessageEvent->replyEventId(!m_threadsEnabled), true, false, this);
bool hasModel = hasComponentType(MessageComponentType::Reply);
if (m_replyModel && !hasModel) {
int insertRow = 0;
if (m_components.first().type == MessageComponentType::Author) {
insertRow = 1;
}
beginInsertRows({}, insertRow, insertRow);
m_components.insert(insertRow, MessageComponent{MessageComponentType::Reply, QString(), {}});
} else if (!m_replyModel && hasModel) {
int removeRow = 0;
if (m_components.first().type == MessageComponentType::Author) {
removeRow = 1;
}
beginRemoveRows({}, removeRow, removeRow);
m_components.removeAt(removeRow);
endRemoveRows();
}
}
QList<MessageComponent> EventMessageContentModel::componentsForType(MessageComponentType::Type type)
{
const auto [event, _] = m_room->getEvent(m_eventId);
if (event == nullptr) {
return {};
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event);
switch (type) {
case MessageComponentType::Verification: {
return {MessageComponent{MessageComponentType::Verification, QString(), {}}};
}
case MessageComponentType::Text: {
return TextHandler().textComponents(EventHandler::rawMessageBody(*event),
EventHandler::messageBodyInputFormat(*event),
m_room,
event,
roomMessageEvent ? roomMessageEvent->isReplaced() : false);
}
case MessageComponentType::File: {
QList<MessageComponent> components;
components += MessageComponent{MessageComponentType::File, {}, EventHandler::mediaInfo(m_room, event)};
auto body = EventHandler::rawMessageBody(*event);
if (!body.isEmpty()) {
components += TextHandler().textComponents(body,
EventHandler::messageBodyInputFormat(*event),
m_room,
event,
roomMessageEvent ? roomMessageEvent->isReplaced() : false);
}
return components;
}
case MessageComponentType::Image:
case MessageComponentType::Audio:
case MessageComponentType::Video: {
QList<MessageComponent> components = {MessageComponent{type, EventHandler::richBody(m_room, event), EventHandler::mediaInfo(m_room, event)}};
if (!event->is<StickerEvent>() && roomMessageEvent) {
const auto fileContent = roomMessageEvent->get<EventContent::FileContentBase>();
if (fileContent != nullptr) {
const auto fileInfo = fileContent->commonInfo();
const auto body = EventHandler::rawMessageBody(*roomMessageEvent);
// Do not attach the description to the image, if it's the same as the original filename.
if (fileInfo.originalName != body) {
components += TextHandler().textComponents(body,
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
}
}
}
return components;
}
case MessageComponentType::Location:
return {MessageComponent{type,
EventHandler::plainBody(m_room, event),
{
{u"latitude"_s, EventHandler::latitude(event)},
{u"longitude"_s, EventHandler::longitude(event)},
{u"asset"_s, EventHandler::locationAssetType(event)},
}}};
default:
return {MessageComponent{type, QString(), {}}};
}
}
void EventMessageContentModel::updateItineraryModel()
{
if (!hasComponentType(MessageComponentType::File) || !m_room) {
return;
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(m_room->getEvent(m_eventId).first);
if (!roomMessageEvent || !roomMessageEvent->has<EventContent::FileContent>()) {
return;
}
auto filePath = m_room->cachedFileTransferInfo(roomMessageEvent).localPath;
if (filePath.isEmpty() && m_itineraryModel != nullptr) {
delete m_itineraryModel;
m_itineraryModel = nullptr;
} else if (!filePath.isEmpty()) {
if (m_itineraryModel == nullptr) {
m_itineraryModel = new ItineraryModel(this);
connect(m_itineraryModel, &ItineraryModel::loaded, this, [this]() {
if (m_itineraryModel->rowCount() == 0) {
m_emptyItinerary = true;
m_itineraryModel->deleteLater();
m_itineraryModel = nullptr;
}
Q_EMIT itineraryUpdated();
});
connect(m_itineraryModel, &ItineraryModel::loadErrorOccurred, this, [this]() {
m_emptyItinerary = true;
m_itineraryModel->deleteLater();
m_itineraryModel = nullptr;
Q_EMIT itineraryUpdated();
});
}
m_itineraryModel->setPath(filePath.toString());
}
}
void EventMessageContentModel::updateReactionModel()
{
if (m_reactionModel && m_reactionModel->rowCount() > 0) {
return;
}
if (m_reactionModel == nullptr) {
m_reactionModel = new ReactionModel(this, m_eventId, m_room);
connect(m_reactionModel, &ReactionModel::reactionsUpdated, this, &EventMessageContentModel::updateReactionModel);
}
if (m_reactionModel->rowCount() <= 0) {
m_reactionModel->disconnect(this);
m_reactionModel->deleteLater();
m_reactionModel = nullptr;
}
if (m_reactionModel && m_components.last().type != MessageComponentType::Reaction) {
beginInsertRows({}, rowCount(), rowCount());
m_components += MessageComponent{MessageComponentType::Reaction, QString(), {}};
endInsertRows();
} else if (rowCount() > 0 && m_components.last().type == MessageComponentType::Reaction) {
beginRemoveRows({}, rowCount() - 1, rowCount() - 1);
m_components.removeLast();
endRemoveRows();
}
}
ThreadModel *EventMessageContentModel::modelForThread(const QString &threadRootId)
{
return ContentProvider::self().modelForThread(m_room, threadRootId);
}
void EventMessageContentModel::setThreadsEnabled(bool enableThreads)
{
m_threadsEnabled = enableThreads;
}
#include "moc_eventmessagecontentmodel.cpp"