// SPDX-FileCopyrightText: 2024 James Graham // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include "messagemodel.h" #include #include #include #include #include #include #include "contentprovider.h" #include "enums/delegatetype.h" #include "enums/messagecomponenttype.h" #include "eventhandler.h" #include "events/pollevent.h" #include "models/reactionmodel.h" #include "models/eventmessagecontentmodel.h" #include "neochatroommember.h" using namespace Quotient; std::function MessageModel::m_hiddenFilter = [](const Quotient::RoomEvent *) -> bool { return false; }; bool MessageModel::m_threadsEnabled = false; MessageModel::MessageModel(QObject *parent) : QAbstractListModel(parent) { connect(this, &MessageModel::newEventAdded, this, &MessageModel::createEventObjects, Qt::QueuedConnection); connect(this, &MessageModel::modelAboutToReset, this, [this]() { m_resetting = true; }); connect(this, &MessageModel::modelReset, this, [this]() { m_resetting = false; }); connect(this, &MessageModel::threadsEnabledChanged, this, [this]() { Q_EMIT dataChanged(index(0), index(rowCount() - 1), {DelegateTypeRole, ContentModelRole, IsThreadedRole, SpecialMarksRole}); }); } NeoChatRoom *MessageModel::room() const { return m_room; } void MessageModel::setRoom(NeoChatRoom *room) { if (room == m_room) { return; } const auto oldRoom = m_room; Q_EMIT roomAboutToChange(oldRoom, room); clearModel(); if (!m_resetting) { m_resetting = true; Q_EMIT modelAboutToReset(); beginResetModel(); } m_room = room; if (m_room != nullptr) { m_room->setVisible(true); } if (m_resetting) { endResetModel(); Q_EMIT modelResetComplete(); m_resetting = false; } Q_EMIT roomChanged(oldRoom, m_room); } int MessageModel::timelineServerIndex() const { return 0; } QPersistentModelIndex MessageModel::readMarkerIndex() const { return m_lastReadEventIndex; } std::optional> MessageModel::getEventForIndex(QModelIndex index) const { Q_UNUSED(index) return std::nullopt; } static NeochatRoomMember *emptyNeochatRoomMember = new NeochatRoomMember; QVariant MessageModel::data(const QModelIndex &idx, int role) const { if (!checkIndex(idx, QAbstractItemModel::CheckIndexOption::IndexIsValid)) { return {}; } const auto row = idx.row(); if (!m_room || row < 0 || row >= rowCount()) { return {}; }; if (m_lastReadEventIndex.row() == row) { switch (role) { case DelegateTypeRole: return DelegateType::ReadMarker; case TimeRole: { const QDateTime eventDate = data(index(m_lastReadEventIndex.row() + 1, 0), TimeRole).toDateTime().toLocalTime(); static const KFormat format; return format.formatRelativeDateTime(eventDate, QLocale::ShortFormat); } case SpecialMarksRole: // Check if all the earlier events in the timeline are hidden. If so hide this. for (auto r = row - 1; r >= 0; --r) { const auto specialMark = index(r).data(SpecialMarksRole); if (!(specialMark == EventStatus::Hidden || specialMark == EventStatus::Replaced)) { return EventStatus::Normal; } } return EventStatus::Hidden; } return {}; } bool isPending = row < timelineServerIndex(); const auto event = getEventForIndex(idx); if (!event.has_value()) { return {}; } // Figure out the correct room for this event. // This is unusual for a timeline model, but not for our search model which can span multiple room versions. NeoChatRoom *eventRoom = m_room; if (event->get().roomId() != m_room->id()) { // Grab the associated room *if* we can find it. if (const auto room = dynamic_cast(m_room->connection()->room(event->get().roomId()))) { eventRoom = room; } } if (role == Qt::DisplayRole) { return EventHandler::richBody(eventRoom, &event.value().get()); } if (role == ContentModelRole) { if (event->get().is() || event->get().is() || event->get().is()) { return QVariant::fromValue(ContentProvider::self().contentModelForEvent(eventRoom, event->get().id())); } auto roomMessageEvent = eventCast(&event.value().get()); if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded()) { return QVariant::fromValue( ContentProvider::self().contentModelForEvent(eventRoom, roomMessageEvent->threadRootEventId())); } return QVariant::fromValue(ContentProvider::self().contentModelForEvent(eventRoom, &event->get())); } if (role == GenericDisplayRole) { return EventHandler::genericBody(eventRoom, &event.value().get()); } if (role == DelegateTypeRole) { return DelegateType::typeForEvent(event.value().get()); } if (role == AuthorRole) { QString mId; if (isPending) { mId = eventRoom->localMember().id(); } else { mId = event.value().get().senderId(); } return QVariant::fromValue(eventRoom->qmlSafeMember(mId)); } if (role == HighlightRole) { return EventHandler::isHighlighted(eventRoom, &event.value().get()); } if (role == SpecialMarksRole) { if (isPending) { // A pending event with an m.new_content key will be merged into the // original event so don't show. if (event.value().get().contentJson().contains("m.new_content"_L1)) { return EventStatus::Hidden; } const auto pendingIt = eventRoom->findPendingEvent(event->get().transactionId()); if (pendingIt == eventRoom->pendingEvents().end()) { return EventStatus::Hidden; } return pendingIt->deliveryStatus(); } if (EventHandler::isHidden(eventRoom, &event.value().get(), m_hiddenFilter)) { return EventStatus::Hidden; } auto roomMessageEvent = eventCast(&event.value().get()); if (m_threadsEnabled && roomMessageEvent && (roomMessageEvent->isThreaded() || eventRoom->threads().contains(event.value().get().id()))) { const auto &thread = eventRoom->threads().value(roomMessageEvent->isThreaded() ? roomMessageEvent->threadRootEventId() : event.value().get().id()); if (thread.latestEventId != event.value().get().id()) { return EventStatus::Hidden; } } return EventStatus::Normal; } if (role == EventIdRole) { return event.value().get().displayId(); } if (role == ProgressInfoRole) { if (auto e = eventCast(&event.value().get())) { if (e->has() || e->has() || e->has() || e->has()) { return QVariant::fromValue(eventRoom->cachedFileTransferInfo(&event.value().get())); } } if (eventCast(&event.value().get())) { return QVariant::fromValue(eventRoom->cachedFileTransferInfo(&event.value().get())); } return {}; } if (role == TimeRole) { return EventHandler::time(eventRoom, &event.value().get(), isPending); } if (role == SectionRole) { return EventHandler::timeString(eventRoom, &event.value().get(), true, QLocale::ShortFormat, isPending); } if (role == IsThreadedRole) { if (!m_threadsEnabled) { return false; } if (auto roomMessageEvent = eventCast(&event.value().get())) { return roomMessageEvent->isThreaded(); } return {}; } if (role == ThreadRootRole) { auto roomMessageEvent = eventCast(&event.value().get()); if (roomMessageEvent && roomMessageEvent->isThreaded()) { return roomMessageEvent->threadRootEventId(); } return {}; } if (role == IsPollRole) { return event->get().is(); } if (role == ShowSectionRole) { for (auto r = row + 1; r < rowCount(); ++r) { auto i = index(r); // Note !itemData(i).empty() is a check for instances where rows have been removed, e.g. when the read marker is moved. // While the row is removed the subsequent row indexes are not changed so we need to skip over the removed index. // See - https://doc.qt.io/qt-5/qabstractitemmodel.html#beginRemoveRows if (data(i, SpecialMarksRole) != EventStatus::Hidden && !itemData(i).empty()) { const auto day = data(idx, TimeRole).toDateTime().toLocalTime().date().dayOfYear(); const auto previousEventDay = data(i, TimeRole).toDateTime().toLocalTime().date().dayOfYear(); return day != previousEventDay; } } return false; } if (role == ReadMarkersRole) { if (m_readMarkerModels.contains(event.value().get().id())) { return QVariant::fromValue(m_readMarkerModels[event.value().get().id()].get()); } else { return QVariantList(); } } if (role == ShowReadMarkersRole) { return m_readMarkerModels.contains(event.value().get().id()); } if (role == VerifiedRole) { if (event.value().get().originalEvent()) { auto encrypted = dynamic_cast(event.value().get().originalEvent()); Q_ASSERT(encrypted); return eventRoom->connection()->isVerifiedSession(encrypted->sessionId().toLatin1()); } return false; } if (role == AuthorDisplayNameRole) { return EventHandler::authorDisplayName(eventRoom, &event.value().get(), isPending); } if (role == IsRedactedRole) { return event.value().get().isRedacted(); } if (role == IsPendingRole) { return row < static_cast(eventRoom->pendingEvents().size()); } if (role == MediaInfoRole) { return EventHandler::mediaInfo(eventRoom, &event.value().get()); } if (role == IsEditableRole) { return MessageComponentType::typeForEvent(event.value().get()) == MessageComponentType::Text && event.value().get().senderId() == eventRoom->localMember().id(); } if (role == ShowAuthorRole) { return true; } if (role == EventTypeRole) { return event.value().get().matrixType(); } return {}; } QHash MessageModel::roleNames() const { QHash roles = QAbstractItemModel::roleNames(); roles[DelegateTypeRole] = "delegateType"; roles[EventIdRole] = "eventId"; roles[TimeRole] = "time"; roles[SectionRole] = "section"; roles[AuthorRole] = "author"; roles[HighlightRole] = "isHighlighted"; roles[SpecialMarksRole] = "marks"; roles[ProgressInfoRole] = "progressInfo"; roles[IsThreadedRole] = "isThreaded"; roles[ThreadRootRole] = "threadRoot"; roles[IsPollRole] = "isPoll"; roles[ShowSectionRole] = "showSection"; roles[ReadMarkersRole] = "readMarkers"; roles[ShowReadMarkersRole] = "showReadMarkers"; roles[VerifiedRole] = "verified"; roles[AuthorDisplayNameRole] = "authorDisplayName"; roles[IsRedactedRole] = "isRedacted"; roles[GenericDisplayRole] = "genericDisplay"; roles[IsPendingRole] = "isPending"; roles[ContentModelRole] = "contentModel"; roles[MediaInfoRole] = "mediaInfo"; roles[IsEditableRole] = "isEditable"; roles[ShowAuthorRole] = "showAuthor"; roles[EventTypeRole] = "eventType"; return roles; } QModelIndex MessageModel::indexForEventId(const QString &eventId) const { const auto matches = match(index(0, 0), EventIdRole, eventId); if (matches.isEmpty()) { qWarning() << "Trying to find non-existent event:" << eventId; return {}; } return matches.constFirst(); } const RoomEvent *MessageModel::findEvent(const QString &eventId) const { const auto index = indexForEventId(eventId); if (const auto event = getEventForIndex(index)) { return &event.value().get(); } return nullptr; } void MessageModel::fullEventRefresh(int row) { auto roles = roleNames().keys(); // The author of an event never changes so should only be updated when a member // changed signal is emitted. // This also avoids any race conditions where a member is updating and this refresh // tries to access a member event that has already been deleted. roles.removeAll(AuthorRole); refreshEventRoles(row, roles); } void MessageModel::refreshEventRoles(int row, const QList &roles) { const auto idx = index(row); Q_EMIT dataChanged(idx, idx, roles); } int MessageModel::refreshEventRoles(const QString &id, const QList &roles) { // On 64-bit platforms, difference_type for std containers is long long // but Qt uses int throughout its interfaces; hence casting to int below. int row = -1; // First try pendingEvents because it is almost always very short. const auto pendingIt = m_room->findPendingEvent(id); if (pendingIt != m_room->pendingEvents().end()) { row = int(pendingIt - m_room->pendingEvents().begin()); } else { const auto timelineIt = m_room->findInTimeline(id); if (timelineIt == m_room->historyEdge()) { return -1; } row = int(timelineIt - m_room->messageEvents().rbegin()) + timelineServerIndex(); if (data(index(row, 0), DelegateTypeRole).toInt() == DelegateType::ReadMarker || data(index(row, 0), DelegateTypeRole).toInt() == DelegateType::Other) { row++; } } refreshEventRoles(row, roles); return row; } void MessageModel::refreshLastUserEvents(int baseTimelineRow) { if (!m_room || m_room->timelineSize() <= baseTimelineRow) { return; } const auto &timelineBottom = m_room->messageEvents().rbegin(); const auto &lastSender = (*(timelineBottom + baseTimelineRow))->senderId(); const auto limit = timelineBottom + std::min(baseTimelineRow + 10, m_room->timelineSize()); for (auto it = timelineBottom + std::max(baseTimelineRow - 10, 0); it != limit; ++it) { if ((*it)->senderId() == lastSender) { fullEventRefresh(it - timelineBottom); } } } void MessageModel::createEventObjects(const Quotient::RoomEvent *event) { if (event == nullptr) { return; } auto eventId = event->id(); if (eventId.isEmpty()) { eventId = event->transactionId(); } // ReadMarkerModel handles updates to add and remove markers, we only need to // handle adding and removing whole models here. if (m_readMarkerModels.contains(eventId)) { // If a model already exists but now has no reactions remove it if (m_readMarkerModels[eventId]->rowCount() <= 0) { m_readMarkerModels.remove(eventId); if (!m_resetting) { refreshEventRoles(eventId, {ReadMarkersRole, ShowReadMarkersRole}); } } } else { auto memberIds = m_room->userIdsAtEvent(eventId); memberIds.remove(m_room->localMember().id()); if (memberIds.size() > 0) { // If a model doesn't exist and there are reactions add it. auto newModel = QSharedPointer(new ReadMarkerModel(eventId, m_room)); if (newModel->rowCount() > 0) { m_readMarkerModels[eventId] = newModel; if (!m_resetting) { refreshEventRoles(eventId, {ReadMarkersRole, ShowReadMarkersRole}); } } } } } void MessageModel::moveReadMarker(const QString &toEventId) { const auto timelineIt = m_room->findInTimeline(toEventId); if (timelineIt == m_room->historyEdge()) { return; } int newRow = int(timelineIt - m_room->messageEvents().rbegin()) + timelineServerIndex(); if (!m_lastReadEventIndex.isValid()) { // Not valid index means we don't display any marker yet, in this case // we create the new index and insert the row in case the read marker // need to be displayed. if (newRow > timelineServerIndex()) { // The user didn't read all the messages yet. beginInsertRows({}, newRow, newRow); m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); endInsertRows(); Q_EMIT readMarkerIndexChanged(); Q_EMIT readMarkerAdded(); return; } // The user read all the messages and we didn't display any read marker yet // => do nothing return; } if (newRow <= timelineServerIndex()) { // The user read all the messages => remove read marker beginRemoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row()); m_lastReadEventIndex = QModelIndex(); endRemoveRows(); Q_EMIT readMarkerIndexChanged(); return; } // The user didn't read all the messages yet but moved the reader marker. beginMoveRows({}, m_lastReadEventIndex.row(), m_lastReadEventIndex.row(), {}, newRow); m_lastReadEventIndex = QPersistentModelIndex(index(newRow, 0)); endMoveRows(); Q_EMIT readMarkerIndexChanged(); } void MessageModel::clearModel() { if (m_room) { const auto oldRoom = m_room; // HACK: Reset the model to a null room first to make sure QML dismantles // last room's objects before the room is actually changed if (!m_resetting) { m_resetting = true; Q_EMIT modelAboutToReset(); beginResetModel(); } m_room->disconnect(this); m_room = nullptr; if (m_resetting) { endResetModel(); Q_EMIT modelResetComplete(); m_resetting = false; } // Because we don't want any of the object deleted before the model is cleared. oldRoom->setVisible(false); } // Don't clear the member objects until the model has been fully reset and all // refs cleared. clearEventObjects(); } void MessageModel::clearEventObjects() { m_readMarkerModels.clear(); } bool MessageModel::event(QEvent *event) { if (event->type() == QEvent::ApplicationPaletteChange) { Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole, ReadMarkersRole}); } return QObject::event(event); } void MessageModel::setHiddenFilter(std::function hiddenFilter) { MessageModel::m_hiddenFilter = hiddenFilter; } void MessageModel::setThreadsEnabled(bool enableThreads) { MessageModel::m_threadsEnabled = enableThreads; } #include "moc_messagemodel.cpp"