diff --git a/imports/NeoChat/Component/Timeline/StateDelegate.qml b/imports/NeoChat/Component/Timeline/StateDelegate.qml index 43b149057..3e94805f0 100644 --- a/imports/NeoChat/Component/Timeline/StateDelegate.qml +++ b/imports/NeoChat/Component/Timeline/StateDelegate.qml @@ -10,41 +10,57 @@ import org.kde.kirigami 2.15 as Kirigami import NeoChat.Component 1.0 import NeoChat.Dialog 1.0 -RowLayout { +Control { x: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing - height: label.contentHeight width: ListView.view.width - Kirigami.Units.largeSpacing - x - Kirigami.Avatar { - id: icon - Layout.preferredWidth: Kirigami.Units.iconSizes.small - Layout.preferredHeight: Kirigami.Units.iconSizes.small - Layout.alignment: Qt.AlignTop - - name: author.displayName - source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : "" - color: author.color - - Component { - id: userDetailDialog - - UserDetailDialog {} - } - - MouseArea { - anchors.fill: parent - onClicked: userDetailDialog.createObject(ApplicationWindow.overlay, {"room": currentRoom, "user": author.object, "displayName": author.displayName, "avatarMediaId": author.avatarMediaId, "avatarUrl": author.avatarUrl}).open() - } + height: sectionDelegate.height + rowLayout.height + SectionDelegate { + id: sectionDelegate + width: parent.width + anchors.top: parent.top + anchors.leftMargin: Kirigami.Units.smallSpacing + visible: model.showSection + height: visible ? implicitHeight : 0 } - Label { - id: label - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - Layout.preferredHeight: icon.height - wrapMode: Text.WordWrap - textFormat: Text.RichText - text: "" + currentRoom.htmlSafeMemberName(author.id) + " " + display - onLinkActivated: userDetailDialog.createObject(ApplicationWindow.overlay, {"room": currentRoom, "user": author.object, "displayName": author.displayName, "avatarMediaId": author.avatarMediaId, "avatarUrl": author.avatarUrl}).open() + RowLayout { + id: rowLayout + height: label.contentHeight + width: parent.width + anchors.bottom: parent.bottom + + Kirigami.Avatar { + id: icon + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + Layout.alignment: Qt.AlignTop + + name: author.displayName + source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : "" + color: author.color + + Component { + id: userDetailDialog + + UserDetailDialog {} + } + + MouseArea { + anchors.fill: parent + onClicked: userDetailDialog.createObject(ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open() + } + } + + Label { + id: label + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + Layout.preferredHeight: icon.height + wrapMode: Text.WordWrap + textFormat: Text.RichText + text: `${currentRoom.htmlSafeMemberName(author.id)} ${aggregateDisplay}` + onLinkActivated: userDetailDialog.createObject(ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open() + } } } diff --git a/imports/NeoChat/Page/RoomPage.qml b/imports/NeoChat/Page/RoomPage.qml index ce4340ebf..ea4463a13 100644 --- a/imports/NeoChat/Page/RoomPage.qml +++ b/imports/NeoChat/Page/RoomPage.qml @@ -239,6 +239,11 @@ Kirigami.ScrollablePage { } } + CollapseStateProxyModel { + id: collapseStateProxyModel + sourceModel: sortedMessageEventModel + } + ListView { id: messageListView visible: !invitation.visible @@ -251,7 +256,7 @@ Kirigami.ScrollablePage { verticalLayoutDirection: ListView.BottomToTop highlightMoveDuration: 500 - model: !isLoaded ? undefined : sortedMessageEventModel + model: !isLoaded ? undefined : collapseStateProxyModel MessageEventModel { id: messageEventModel diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9e7f7c37b..8100099a2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(neochat blurhash.cpp blurhashimageprovider.cpp joinrulesevent.cpp + collapsestateproxymodel.cpp ../res.qrc ) diff --git a/src/collapsestateproxymodel.cpp b/src/collapsestateproxymodel.cpp new file mode 100644 index 000000000..5b1f066a9 --- /dev/null +++ b/src/collapsestateproxymodel.cpp @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "collapsestateproxymodel.h" +#include "messageeventmodel.h" + +#include + +bool CollapseStateProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + return sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::EventTypeRole) + != QLatin1String("state") // If this is not a state, show it + || sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::EventTypeRole) + != QLatin1String("state") // If this is the first state in a block, show it. TODO hidden events? + || sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::ShowSectionRole).toBool() // If it's a new day, show it + || sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::EventResolvedTypeRole) + != sourceModel()->data(sourceModel()->index(source_row + 1, 0), + MessageEventModel::EventResolvedTypeRole) // Also show it if it's of a different type than the one before TODO improve in + || sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::AuthorIdRole) + != sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::AuthorIdRole); // Also show it if it's a different author +} + +QVariant CollapseStateProxyModel::data(const QModelIndex &index, int role) const +{ + if (role == AggregateDisplayRole) { + return aggregateEventToString(mapToSource(index).row()); + } + return sourceModel()->data(mapToSource(index), role); +} + +QHash CollapseStateProxyModel::roleNames() const +{ + auto roles = sourceModel()->roleNames(); + roles[AggregateDisplayRole] = "aggregateDisplay"; + return roles; +} + +QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const +{ + QStringList parts; + for (int i = sourceRow; i >= 0; i--) { + parts += sourceModel()->data(sourceModel()->index(i, 0), Qt::DisplayRole).toString(); + if (sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::EventTypeRole) != QLatin1String("state") // If it's not a state event + || (i > 0 + && sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::EventResolvedTypeRole) + != sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventResolvedTypeRole)) // or of a different type + || (i > 0 + && sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorIdRole) + != sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::AuthorIdRole)) // or by a different author + || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible + ) { + break; + } + } + if (!parts.isEmpty()) { + QStringList chunks; + while (!parts.isEmpty()) { + chunks += QString(); + int count = 1; + auto part = parts.takeFirst(); + chunks.last() += part; + while (!parts.isEmpty() && parts.first() == part) { + parts.removeFirst(); + count++; + } + if (count > 1) { + chunks.last() += i18ncp("[user did something] n times", " %1 time", " %1 times", count); + } + } + QString text = chunks.takeFirst(); + + if (chunks.size() > 0) { + while (chunks.size() > 1) { + text += i18nc("[action 1], [action 2 and action 3]", ", "); + text += chunks.takeFirst(); + } + text += i18nc("[action 1, action 2] and [action 3]", " and "); + text += chunks.takeFirst(); + } + return text; + } else { + return {}; + } +} diff --git a/src/collapsestateproxymodel.h b/src/collapsestateproxymodel.h new file mode 100644 index 000000000..c3af1056b --- /dev/null +++ b/src/collapsestateproxymodel.h @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include + +#include "messageeventmodel.h" + +class CollapseStateProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + enum Roles { + AggregateDisplayRole = MessageEventModel::LastRole + 1, + }; + [[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + [[nodiscard]] QHash roleNames() const override; + [[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; + + [[nodiscard]] QString aggregateEventToString(int row) const; +}; diff --git a/src/main.cpp b/src/main.cpp index f990d61fa..98ab0903d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,7 @@ #include "chatboxhelper.h" #include "chatdocumenthandler.h" #include "clipboard.h" +#include "collapsestateproxymodel.h" #include "commandmodel.h" #include "controller.h" #include "csapi/joining.h" @@ -198,6 +199,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "CustomEmojiModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "PublicRoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserDirectoryListModel"); diff --git a/src/messageeventmodel.cpp b/src/messageeventmodel.cpp index 06e2c9b12..cf2ce1ea0 100644 --- a/src/messageeventmodel.cpp +++ b/src/messageeventmodel.cpp @@ -53,6 +53,7 @@ QHash MessageEventModel::roleNames() const roles[SourceRole] = "source"; roles[MimeTypeRole] = "mimeType"; roles[FormattedBodyRole] = "formattedBody"; + roles[AuthorIdRole] = "authorId"; return roles; } @@ -767,6 +768,9 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return res; } + if (role == AuthorIdRole) { + return evt.senderId(); + } return {}; } diff --git a/src/messageeventmodel.h b/src/messageeventmodel.h index efd9ff566..cc8388fd1 100644 --- a/src/messageeventmodel.h +++ b/src/messageeventmodel.h @@ -47,6 +47,8 @@ public: // For debugging EventResolvedTypeRole, + AuthorIdRole, + LastRole, // Keep this last }; Q_ENUM(EventRoles) diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 8a3df1cb6..d22617228 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -396,6 +396,18 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, [this](const RoomMemberEvent &e) { // FIXME: Rewind to the name that was at the time of this event auto subjectName = this->htmlSafeMemberName(e.userId()); + if (e.membership() == MembershipType::Leave) { + auto displayName = e.prevContent()->displayName; +#ifdef QUOTIENT_07 + if (displayName) { + subjectName = sanitized(*displayName).toHtmlEscaped(); +#else + if (displayName.isEmpty()) { + subjectName = sanitized(displayName).toHtmlEscaped(); +#endif + } + } + // The below code assumes senderName output in AuthorRole switch (e.membership()) { case MembershipType::Invite: