Loading and End of Timeline Delegates

Add delegate for showing the user a loading indicator and for the beginning of the timeline.

BUG: 455045
BUG: 465285
This commit is contained in:
James Graham
2023-11-20 17:10:56 +00:00
parent 0dbef58ff2
commit 5efd17d370
15 changed files with 370 additions and 41 deletions

View File

@@ -141,6 +141,8 @@ add_library(neochat STATIC
colorschemer.h
models/notificationsmodel.cpp
models/notificationsmodel.h
models/timelinemodel.cpp
models/timelinemodel.h
)
qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
@@ -293,6 +295,8 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/SelectSpacesDialog.qml
qml/AttachDialog.qml
qml/NotificationsView.qml
qml/LoadingDelegate.qml
qml/TimelineEndDelegate.qml
RESOURCES
qml/confetti.png
qml/glowdot.png

View File

@@ -40,6 +40,8 @@ public:
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);

View File

@@ -389,13 +389,7 @@ int MessageEventModel::rowCount(const QModelIndex &parent) const
return 0;
}
const auto firstIt = m_currentRoom->messageEvents().crbegin();
if (firstIt != m_currentRoom->messageEvents().crend()) {
const auto &firstEvt = **firstIt;
return m_currentRoom->timelineSize() + (lastReadEventId != firstEvt.id() ? 1 : 0);
} else {
return m_currentRoom->timelineSize();
}
return int(m_currentRoom->pendingEvents().size()) + m_currentRoom->timelineSize() + (m_lastReadEventIndex.isValid() ? 1 : 0);
}
bool MessageEventModel::canFetchMore(const QModelIndex &parent) const
@@ -422,7 +416,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
}
const auto row = idx.row();
if (!m_currentRoom || row < 0 || row >= int(m_currentRoom->pendingEvents().size()) + m_currentRoom->timelineSize()) {
if (!m_currentRoom || row < 0 || row >= rowCount()) {
return {};
};
@@ -465,7 +459,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>")
: i18n("<i>[This message was deleted: %1]</i>", evt.redactedBecause()->reason());
}
return eventHandler.getRichBody();
}

View File

@@ -8,14 +8,15 @@
#include "enums/delegatetype.h"
#include "messageeventmodel.h"
#include "neochatconfig.h"
#include "timelinemodel.h"
using namespace Quotient;
MessageFilterModel::MessageFilterModel(QObject *parent, MessageEventModel *sourceMessageModel)
MessageFilterModel::MessageFilterModel(QObject *parent, TimelineModel *sourceModel)
: QSortFilterProxyModel(parent)
{
Q_ASSERT(sourceMessageModel);
setSourceModel(sourceMessageModel);
Q_ASSERT(sourceModel);
setSourceModel(sourceModel);
connect(NeoChatConfig::self(), &NeoChatConfig::ShowStateEventChanged, this, [this] {
invalidateFilter();

View File

@@ -7,6 +7,7 @@
#include <QSortFilterProxyModel>
#include "messageeventmodel.h"
#include "timelinemodel.h"
/**
* @class MessageFilterModel
@@ -36,7 +37,7 @@ public:
LastRole, // Keep this last
};
explicit MessageFilterModel(QObject *parent = nullptr, MessageEventModel *sourceMessageModel = nullptr);
explicit MessageFilterModel(QObject *parent = nullptr, TimelineModel *sourceModel = nullptr);
/**
* @brief Custom filter function to remove hidden messages.

View File

@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2022 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 "timelinemodel.h"
#include "delegatetype.h"
TimelineModel::TimelineModel(QObject *parent)
: QConcatenateTablesProxyModel(parent)
{
m_messageEventModel = new MessageEventModel(this);
addSourceModel(m_messageEventModel);
m_timelineEndModel = new TimelineEndModel(this);
addSourceModel(m_timelineEndModel);
}
NeoChatRoom *TimelineModel::room() const
{
return m_messageEventModel->room();
}
void TimelineModel::setRoom(NeoChatRoom *room)
{
// Both models do their own null checking so just pass along.
m_messageEventModel->setRoom(room);
m_timelineEndModel->setRoom(room);
}
MessageEventModel *TimelineModel::messageEventModel() const
{
return m_messageEventModel;
}
QHash<int, QByteArray> TimelineModel::roleNames() const
{
return m_messageEventModel->roleNames();
}
TimelineEndModel::TimelineEndModel(QObject *parent)
: QAbstractListModel(parent)
{
}
void TimelineEndModel::setRoom(NeoChatRoom *room)
{
if (room == m_room) {
return;
}
beginResetModel();
if (m_room != nullptr) {
m_room->disconnect(this);
}
m_room = room;
if (m_room != nullptr) {
connect(m_room, &Quotient::Room::eventsHistoryJobChanged, this, [this]() {
if (m_room->allHistoryLoaded()) {
// HACK: We have to do it this way because DelegateChooser doesn't update dynamically.
beginRemoveRows({}, 0, 0);
endRemoveRows();
beginInsertRows({}, 0, 0);
endInsertRows();
}
});
}
endResetModel();
}
QVariant TimelineEndModel::data(const QModelIndex &idx, int role) const
{
Q_UNUSED(idx)
if (m_room == nullptr) {
return {};
}
if (role == DelegateTypeRole) {
return m_room->allHistoryLoaded() ? DelegateType::TimelineEnd : DelegateType::Loading;
}
return {};
}
int TimelineEndModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return 1;
}
QHash<int, QByteArray> TimelineEndModel::roleNames() const
{
return {{DelegateTypeRole, "delegateType"}};
}

112
src/models/timelinemodel.h Normal file
View File

@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2022 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 <QConcatenateTablesProxyModel>
#include <QQmlEngine>
#include "messageeventmodel.h"
#include "neochatroom.h"
/**
* @class TimelineEndModel
*
* A model to provide a single delegate to mark the end of the timeline.
*
* The delegate will either be a loading delegate if more events are being loaded
* or a timeline end delegate if all history is loaded.
*/
class TimelineEndModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
DelegateTypeRole = MessageEventModel::DelegateTypeRole, /**< The delegate type of the message. */
};
Q_ENUM(Roles)
explicit TimelineEndModel(QObject *parent = nullptr);
/**
* @brief Set the room for the timeline.
*/
void setRoom(NeoChatRoom *room);
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
/**
* @brief 1, the answer is always 1.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a map with DelegateTypeRole it's the only one.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
private:
NeoChatRoom *m_room = nullptr;
};
/**
* @class TimelineModel
*
* A model to visualise a room timeline.
*
* This model combines a MessageEventModel with a TimelineEndModel.
*
* @sa MessageEventModel, TimelineEndModel
*/
class TimelineModel : public QConcatenateTablesProxyModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The current room that the model is getting its messages from.
*/
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
/**
* @brief The MessageEventModel for the timeline.
*/
Q_PROPERTY(MessageEventModel *messageEventModel READ messageEventModel CONSTANT)
public:
TimelineModel(QObject *parent = nullptr);
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
MessageEventModel *messageEventModel() const;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractProxyModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_SIGNALS:
void roomChanged();
private:
MessageEventModel *m_messageEventModel = nullptr;
TimelineEndModel *m_timelineEndModel = nullptr;
};

View File

@@ -99,6 +99,7 @@ DelegateChooser {
connection: root.connection
}
}
DelegateChoice {
roleValue: DelegateType.LiveLocation
delegate: LiveLocationDelegate {
@@ -107,6 +108,18 @@ DelegateChooser {
}
}
DelegateChoice {
roleValue: DelegateType.Loading
delegate: LoadingDelegate {}
}
DelegateChoice {
roleValue: DelegateType.TimelineEnd
delegate: TimelineEndDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Other
delegate: Item {}

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2023 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 org.kde.kirigami as Kirigami
import org.kde.neochat
TimelineDelegate {
id: root
contentItem: Kirigami.PlaceholderMessage {
text: i18n("Loading…")
}
}

View File

@@ -23,17 +23,17 @@ Kirigami.Page {
required property NeoChatConnection connection
/**
* @brief The MessageEventModel to use.
* @brief The TimelineModel to use.
*
* Required so that new events can be requested when the end of the current
* local timeline is reached.
*
* @note For loading a room in a different window, override this with a new
* MessageEventModel set with the room to be shown.
* TimelineModel set with the room to be shown.
*
* @sa MessageEventModel
* @sa TimelineModel
*/
property MessageEventModel messageEventModel: RoomManager.messageEventModel
property TimelineModel timelineModel: RoomManager.timelineModel
/**
* @brief The MessageFilterModel to use.
@@ -41,9 +41,9 @@ Kirigami.Page {
* This model has the filtered list of events that should be shown in the timeline.
*
* @note For loading a room in a different window, override this with a new
* MessageFilterModel with the new MessageEventModel as the source model.
* MessageFilterModel with the new TimelineModel as the source model.
*
* @sa MessageEventModel, MessageFilterModel
* @sa TimelineModel, MessageFilterModel
*/
property MessageFilterModel messageFilterModel: RoomManager.messageFilterModel
@@ -56,7 +56,7 @@ Kirigami.Page {
* @note For loading a room in a different window, override this with a new
* MediaMessageFilterModel with the new MessageFilterModel as the source model.
*
* @sa MessageEventModel, MessageFilterModel
* @sa TimelineModel, MessageFilterModel
*/
property MediaMessageFilterModel mediaMessageFilterModel: RoomManager.mediaMessageFilterModel
@@ -120,7 +120,7 @@ Kirigami.Page {
sourceComponent: TimelineView {
id: timelineView
currentRoom: root.currentRoom
messageEventModel: root.messageEventModel
timelineModel: root.timelineModel
messageFilterModel: root.messageFilterModel
actionsHandler: root.actionsHandler
onFocusChatBar: {

View File

@@ -29,7 +29,7 @@ Kirigami.ApplicationWindow {
disableCancelShortcut: true
connection: root.connection
messageEventModel: MessageEventModel {
timelineModel: TimelineModel {
room: currentRoom
}
messageFilterModel: MessageFilterModel {

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2023 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 QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.neochat
TimelineDelegate {
id: root
/**
* @brief The current room that user is viewing.
*/
required property NeoChatRoom room
contentItem: ColumnLayout {
RowLayout {
Layout.topMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.large
Layout.preferredHeight: Kirigami.Units.iconSizes.large
name: root.room ? root.room.displayName : ""
source: root.room && root.room.avatarMediaId ? ("image://mxc/" + root.room.avatarMediaId) : ""
Rectangle {
visible: room.usesEncryption
color: Kirigami.Theme.backgroundColor
width: Kirigami.Units.gridUnit
height: Kirigami.Units.gridUnit
anchors {
bottom: parent.bottom
right: parent.right
}
radius: Math.round(width / 2)
Kirigami.Icon {
source: "channel-secure-symbolic"
anchors.fill: parent
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 0
Kirigami.Heading {
Layout.fillWidth: true
text: root.room ? root.room.displayName : i18n("No name")
textFormat: Text.PlainText
wrapMode: Text.Wrap
}
Kirigami.SelectableLabel {
Layout.fillWidth: true
font: Kirigami.Theme.smallFont
textFormat: TextEdit.PlainText
visible: root.room && root.room.canonicalAlias
text: root.room && root.room.canonicalAlias ? root.room.canonicalAlias : ""
}
}
}
Kirigami.SelectableLabel {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
text: i18n("This is the beginning of the chat. There are no historical messages beyond this point.")
wrapMode: Text.Wrap
onLinkActivated: link => UrlHelper.openUrl(link)
}
}
}

View File

@@ -28,12 +28,12 @@ QQC2.ScrollView {
property bool roomChanging: false
/**
* @brief The MessageEventModel to use.
* @brief The TimelineModel to use.
*
* Required so that new events can be requested when the end of the current
* local timeline is reached.
*/
required property MessageEventModel messageEventModel
required property TimelineModel timelineModel
/**
* @brief The MessageFilterModel to use.
@@ -85,16 +85,16 @@ QQC2.ScrollView {
running: messageListView.atYBeginning
triggeredOnStart: true
onTriggered: {
if (messageListView.atYBeginning && root.messageEventModel.canFetchMore(root.messageEventModel.index(0, 0))) {
root.messageEventModel.fetchMore(root.messageEventModel.index(0, 0));
if (messageListView.atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) {
root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0));
}
}
repeat: true
}
// HACK: The view should do this automatically but doesn't.
onAtYBeginningChanged: if (atYBeginning && root.messageEventModel.canFetchMore(root.messageEventModel.index(0, 0))) {
root.messageEventModel.fetchMore(root.messageEventModel.index(0, 0));
onAtYBeginningChanged: if (atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) {
root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0));
}
Timer {
@@ -270,7 +270,7 @@ QQC2.ScrollView {
}
Connections {
target: root.messageEventModel
target: root.timelineModel
function onRowsInserted() {
markReadIfVisibleTimer.restart()
@@ -311,7 +311,7 @@ QQC2.ScrollView {
Connections {
//enabled: Config.showFancyEffects
target: root.messageEventModel
target: root.timelineModel.messageEventModel
function onFancyEffectsReasonFound(fancyEffect) {
fancyEffectsContainer.processFancyEffectsReason(fancyEffect)
@@ -336,10 +336,10 @@ QQC2.ScrollView {
}
function eventToIndex(eventID) {
const index = root.messageEventModel.eventIdToRow(eventID)
const index = root.timelineModel.messageEventModel.eventIdToRow(eventID)
if (index === -1)
return -1
return root.messageFilterModel.mapFromSource(root.messageEventModel.index(index, 0)).row
return root.messageFilterModel.mapFromSource(root.timelineModel.index(index, 0)).row
}
function firstVisibleIndex() {

View File

@@ -8,8 +8,10 @@
#include "controller.h"
#include "enums/delegatetype.h"
#include "models/messageeventmodel.h"
#include "models/timelinemodel.h"
#include "neochatconfig.h"
#include "neochatroom.h"
#include <KLocalizedString>
#include <QDesktopServices>
#include <QQuickTextDocument>
@@ -29,14 +31,14 @@ RoomManager::RoomManager(QObject *parent)
, m_currentRoom(nullptr)
, m_lastCurrentRoom(nullptr)
, m_config(KSharedConfig::openStateConfig())
, m_messageEventModel(new MessageEventModel(this))
, m_messageFilterModel(new MessageFilterModel(this, m_messageEventModel))
, m_timelineModel(new TimelineModel(this))
, m_messageFilterModel(new MessageFilterModel(this, m_timelineModel))
, m_mediaMessageFilterModel(new MediaMessageFilterModel(this, m_messageFilterModel))
{
m_lastRoomConfig = m_config->group(QStringLiteral("LastOpenRoom"));
connect(this, &RoomManager::currentRoomChanged, this, [this]() {
m_messageEventModel->setRoom(m_currentRoom);
m_timelineModel->setRoom(m_currentRoom);
});
}
@@ -55,9 +57,9 @@ NeoChatRoom *RoomManager::currentRoom() const
return m_currentRoom;
}
MessageEventModel *RoomManager::messageEventModel() const
TimelineModel *RoomManager::timelineModel() const
{
return m_messageEventModel;
return m_timelineModel;
}
MessageFilterModel *RoomManager::messageFilterModel() const

View File

@@ -15,6 +15,7 @@
#include "models/mediamessagefiltermodel.h"
#include "models/messageeventmodel.h"
#include "models/messagefiltermodel.h"
#include "models/timelinemodel.h"
class NeoChatRoom;
class NeoChatConnection;
@@ -48,7 +49,7 @@ class RoomManager : public QObject, public UriResolverBase
Q_PROPERTY(NeoChatRoom *currentRoom READ currentRoom NOTIFY currentRoomChanged)
/**
* @brief The MessageEventModel that should be used for room message visualisation.
* @brief The TimelineModel that should be used for room message visualisation.
*
* The room object the model uses to get the data will be updated by this class
* so there is no need to do this manually or replace the model when a room
@@ -57,7 +58,7 @@ class RoomManager : public QObject, public UriResolverBase
* @note Available here so that the room page and drawer both have access to the
* same model.
*/
Q_PROPERTY(MessageEventModel *messageEventModel READ messageEventModel CONSTANT)
Q_PROPERTY(TimelineModel *timelineModel READ timelineModel CONSTANT)
/**
* @brief The MessageFilterModel that should be used for room message visualisation.
@@ -101,7 +102,7 @@ public:
NeoChatRoom *currentRoom() const;
MessageEventModel *messageEventModel() const;
TimelineModel *timelineModel() const;
MessageFilterModel *messageFilterModel() const;
MediaMessageFilterModel *mediaMessageFilterModel() const;
@@ -383,7 +384,7 @@ private:
KConfigGroup m_lastRoomConfig;
QPointer<ChatDocumentHandler> m_chatDocumentHandler;
MessageEventModel *m_messageEventModel;
TimelineModel *m_timelineModel;
MessageFilterModel *m_messageFilterModel;
MediaMessageFilterModel *m_mediaMessageFilterModel;
NeoChatConnection *m_connection;