diff --git a/src/models/collapsestateproxymodel.cpp b/src/models/collapsestateproxymodel.cpp index 87a12212a..c06401666 100644 --- a/src/models/collapsestateproxymodel.cpp +++ b/src/models/collapsestateproxymodel.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.0-or-later #include "collapsestateproxymodel.h" +#include "messageeventmodel.h" #include @@ -12,18 +13,17 @@ bool CollapseStateProxyModel::filterAcceptsRow(int source_row, const QModelIndex != MessageEventModel::DelegateType::State // If this is not a state, show it || sourceModel()->data(sourceModel()->index(source_row + 1, 0), MessageEventModel::EventTypeRole) != MessageEventModel::DelegateType::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 + || sourceModel()->data(sourceModel()->index(source_row, 0), MessageEventModel::ShowSectionRole).toBool(); // If it's a new day, show it } QVariant CollapseStateProxyModel::data(const QModelIndex &index, int role) const { if (role == AggregateDisplayRole) { return aggregateEventToString(mapToSource(index).row()); + } else if (role == StateEventsRole) { + return stateEventsList(mapToSource(index).row()); + } else if (role == AuthorListRole) { + return authorList(mapToSource(index).row()); } return sourceModel()->data(mapToSource(index), role); } @@ -32,27 +32,29 @@ QHash CollapseStateProxyModel::roleNames() const { auto roles = sourceModel()->roleNames(); roles[AggregateDisplayRole] = "aggregateDisplay"; + roles[StateEventsRole] = "stateEvents"; + roles[AuthorListRole] = "authorList"; return roles; } QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const { QStringList parts; + QVariantList uniqueAuthors; 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) + parts += sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::GenericDisplayRole).toString(); + QVariant nextAuthor = sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole); + if (!uniqueAuthors.contains(nextAuthor)) { + uniqueAuthors.append(nextAuthor); + } + if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole) != MessageEventModel::DelegateType::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; } } + parts.sort(); // Sort them so that all identical events can be collected. if (!parts.isEmpty()) { QStringList chunks; while (!parts.isEmpty()) { @@ -64,18 +66,27 @@ QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const parts.removeFirst(); count++; } - if (count > 1) { - chunks.last() += i18ncp("[user did something] n times", " %1 time", " %1 times", count); + if (count > 1 && uniqueAuthors.length() == 1) { + chunks.last() += i18ncp("n times", " %1 time ", " %1 times ", count); } } - QString text = chunks.takeFirst(); + chunks.removeDuplicates(); + QString text = ""; // There can be links in the event text so make sure all are styled. + // The author text is either "n users" if > 1 user or the matrix.to link to a single user. + QString userText = uniqueAuthors.length() > 1 ? i18ncp("n users", " %1 user ", " %1 users ", uniqueAuthors.length()) + : QStringLiteral("%3 ") + .arg(uniqueAuthors[0].toMap()["id"].toString(), + uniqueAuthors[0].toMap()["color"].toString(), + uniqueAuthors[0].toMap()["displayName"].toString()); + text += userText; + text += chunks.takeFirst(); if (chunks.size() > 0) { while (chunks.size() > 1) { - text += i18nc("[action 1], [action 2 and action 3]", ", "); + text += i18nc("[action 1], [action 2 and/or action 3]", ", "); text += chunks.takeFirst(); } - text += i18nc("[action 1, action 2] and [action 3]", " and "); + text += uniqueAuthors.length() > 1 ? i18nc("[action 1, action 2] or [action 3]", " or ") : i18nc("[action 1, action 2] and [action 3]", " and "); text += chunks.takeFirst(); } return text; @@ -83,3 +94,41 @@ QString CollapseStateProxyModel::aggregateEventToString(int sourceRow) const return {}; } } + +QVariantList CollapseStateProxyModel::stateEventsList(int sourceRow) const +{ + QVariantList stateEvents; + for (int i = sourceRow; i >= 0; i--) { + auto nextState = QVariantMap{ + {"author", sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole)}, + {"authorDisplayName", sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorDisplayNameRole).toString()}, + {"text", sourceModel()->data(sourceModel()->index(i, 0), Qt::DisplayRole).toString()}, + }; + stateEvents.append(nextState); + if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole) + != MessageEventModel::DelegateType::State // If it's not a state event + || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible + ) { + break; + } + } + return stateEvents; +} + +QVariantList CollapseStateProxyModel::authorList(int sourceRow) const +{ + QVariantList uniqueAuthors; + for (int i = sourceRow; i >= 0; i--) { + QVariant nextAvatar = sourceModel()->data(sourceModel()->index(i, 0), MessageEventModel::AuthorRole); + if (!uniqueAuthors.contains(nextAvatar)) { + uniqueAuthors.append(nextAvatar); + } + if (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::EventTypeRole) + != MessageEventModel::DelegateType::State // If it's not a state event + || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible + ) { + break; + } + } + return uniqueAuthors; +} diff --git a/src/models/collapsestateproxymodel.h b/src/models/collapsestateproxymodel.h index d329df5b9..07393f0a0 100644 --- a/src/models/collapsestateproxymodel.h +++ b/src/models/collapsestateproxymodel.h @@ -12,10 +12,29 @@ class CollapseStateProxyModel : public QSortFilterProxyModel public: enum Roles { AggregateDisplayRole = MessageEventModel::LastRole + 1, + StateEventsRole, + AuthorListRole, }; [[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; + /** + * @brief QString aggregating the text of consecutive state events starting at row. + * + * If state events happen on different days they will be split into two aggregate + * events. + */ [[nodiscard]] QString aggregateEventToString(int row) const; + /** + * @brief Return a list of consecutive state events starting at row. + * + * If state events happen on different days they will be split into two aggregate + * events. + */ + [[nodiscard]] QVariantList stateEventsList(int row) const; + /** + * @brief List of unique authors for the aggregate state events starting at row. + */ + [[nodiscard]] QVariantList authorList(int row) const; }; diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 1ab72c703..1279be9a0 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -67,6 +67,7 @@ QHash MessageEventModel::roleNames() const roles[IsNameChangeRole] = "isNameChange"; roles[IsAvatarChangeRole] = "isAvatarChange"; roles[IsRedactedRole] = "isRedacted"; + roles[GenericDisplayRole] = "genericDisplay"; return roles; } @@ -462,6 +463,14 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return m_currentRoom->eventToString(evt, Qt::RichText); } + if (role == GenericDisplayRole) { + if (evt.isRedacted()) { + return i18n("[This message was deleted]"); + } + + return m_currentRoom->eventToGenericString(evt); + } + if (role == FormattedBodyRole) { if (auto e = eventCast(&evt)) { if (e->hasTextContent() && e->mimeType().name() != "text/plain") { diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 1c1a60dd2..982b57eed 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -45,6 +45,7 @@ public: AnnotationRole, UserMarkerRole, FormattedBodyRole, + GenericDisplayRole, MimeTypeRole, FileMimetypeIcon, diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index c6c11d79e..01b5300ac 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -658,6 +658,135 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, i18n("Unknown event")); } +QString NeoChatRoom::eventToGenericString(const RoomEvent &evt) const +{ +#ifdef QUOTIENT_07 + return switchOnType( +#else + return visit( +#endif + evt, + [](const RoomMessageEvent &e) { + Q_UNUSED(e) + return i18n("sent a message"); + }, + [](const StickerEvent &e) { + Q_UNUSED(e) + return i18n("sent a sticker"); + }, + [](const RoomMemberEvent &e) { + switch (e.membership()) { + case MembershipType::Invite: + if (e.repeatsState()) { + return i18n("reinvited someone to the room"); + } + Q_FALLTHROUGH(); + case MembershipType::Join: { + QString text{}; + // Part 1: invites and joins + if (e.repeatsState()) { + text = i18n("joined the room (repeated)"); + } else if (e.changesMembership()) { + text = e.membership() == MembershipType::Invite ? i18n("invited someone to the room") : i18n("joined the room"); + } + if (!text.isEmpty()) { + return text; + } + // Part 2: profile changes of joined members + if (e.isRename()) { + if (e.displayName().isEmpty()) { + text = i18nc("their refers to a singular user", "cleared their display name"); + } else { + text = i18nc("their refers to a singular user", "changed their display name"); + } + } + if (e.isAvatarUpdate()) { + if (!text.isEmpty()) { + text += i18n(" and "); + } + if (e.avatarUrl().isEmpty()) { + text += i18nc("their refers to a singular user", "cleared their avatar"); +#ifdef QUOTIENT_07 + } else if (!e.prevContent()->avatarUrl) { +#else + } else if (e.prevContent()->avatarUrl.isEmpty()) { +#endif + text += i18n("set an avatar"); + } else { + text += i18nc("their refers to a singular user", "updated their avatar"); + } + } + if (text.isEmpty()) { + text = i18nc(" changed nothing", "changed nothing"); + } + return text; + } + case MembershipType::Leave: + if (e.prevContent() && e.prevContent()->membership == MembershipType::Invite) { + return (e.senderId() != e.userId()) ? i18n("withdrew a user's invitation") : i18n("rejected the invitation"); + } + + if (e.prevContent() && e.prevContent()->membership == MembershipType::Ban) { + return (e.senderId() != e.userId()) ? i18n("unbanned a user") : i18n("self-unbanned"); + } + return (e.senderId() != e.userId()) ? i18n("put a user out of the room") : i18n("left the room"); + case MembershipType::Ban: + if (e.senderId() != e.userId()) { + return i18n("banned a user from the room"); + } else { + return i18n("self-banned from the room"); + } + case MembershipType::Knock: { + return i18n("requested an invite"); + } + default:; + } + return i18n("made something unknown"); + }, + [](const RoomCanonicalAliasEvent &e) { + return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias"); + }, + [](const RoomNameEvent &e) { + return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name"); + }, + [](const RoomTopicEvent &e) { + return (e.topic().isEmpty()) ? i18n("cleared the topic") : i18n("set the topic"); + }, + [](const RoomAvatarEvent &) { + return i18n("changed the room avatar"); + }, + [](const EncryptionEvent &) { + return i18n("activated End-to-End Encryption"); + }, + [](const RoomCreateEvent &e) { + return e.isUpgrade() ? i18n("upgraded the room version") : i18n("created the room"); + }, + [](const RoomPowerLevelsEvent &) { + return i18nc("'power level' means permission level", "changed the power levels for this room"); + }, + [](const StateEventBase &e) { + if (e.matrixType() == QLatin1String("m.room.server_acl")) { + return i18n("changed the server access control lists for this room"); + } + if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) { + if (e.fullJson()["unsigned"]["prev_content"].toObject().isEmpty()) { + return i18n("added a widget"); + } + if (e.contentJson().isEmpty()) { + return i18n("removed a widget"); + } + return i18n("configured a widget"); + } + return i18n("updated the state"); + }, +#ifdef QUOTIENT_07 + [](const PollStartEvent &e) { + return i18n("started a poll"); + }, +#endif + i18n("Unknown event")); +} + void NeoChatRoom::changeAvatar(const QUrl &localFile) { const auto job = connection()->uploadFile(localFile.toLocalFile()); diff --git a/src/neochatroom.h b/src/neochatroom.h index ace379b4d..fcd29689a 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -249,6 +249,7 @@ public: [[nodiscard]] QString avatarMediaId() const; [[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool removeReply = true) const; + [[nodiscard]] QString eventToGenericString(const Quotient::RoomEvent &evt) const; Q_INVOKABLE [[nodiscard]] bool containsUser(const QString &userID) const; Q_INVOKABLE [[nodiscard]] bool isUserBanned(const QString &user) const; diff --git a/src/qml/Component/Timeline/StateComponent.qml b/src/qml/Component/Timeline/StateComponent.qml new file mode 100644 index 000000000..f6df3e455 --- /dev/null +++ b/src/qml/Component/Timeline/StateComponent.qml @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.15 as Kirigami + +import org.kde.neochat 1.0 + +RowLayout { + id: root + property var name + property alias avatar: stateAvatar.source + property var color + property alias text: label.text + + signal avatarClicked() + signal linkClicked(string link) + + implicitHeight: Math.max(label.contentHeight, stateAvatar.implicitHeight) + + Kirigami.Avatar { + id: stateAvatar + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + + name: root.name + color: root.color + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: avatarClicked() + } + } + + QQC2.Label { + id: label + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + textFormat: Text.RichText + onLinkActivated: linkClicked(link) + } +} diff --git a/src/qml/Component/Timeline/StateDelegate.qml b/src/qml/Component/Timeline/StateDelegate.qml index 78e2ea35b..71a1767e8 100644 --- a/src/qml/Component/Timeline/StateDelegate.qml +++ b/src/qml/Component/Timeline/StateDelegate.qml @@ -51,6 +51,9 @@ QQC2.Control { ColumnLayout { id: columnLayout + + property bool folded: true + spacing: sectionVisible ? Kirigami.Units.largeSpacing : 0 anchors.top: parent.top anchors.topMargin: sectionVisible ? 0 : Kirigami.Units.largeSpacing @@ -63,45 +66,73 @@ QQC2.Control { visible: sectionVisible labelText: sectionVisible ? section : "" } - RowLayout { - id: rowLayout - implicitHeight: label.contentHeight Layout.fillWidth: true Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0) Layout.rightMargin: Kirigami.Units.largeSpacing + visible: stateEventRepeater.count !== 1 - Kirigami.Avatar { - id: icon - Layout.preferredWidth: Kirigami.Units.iconSizes.small - Layout.preferredHeight: Kirigami.Units.iconSizes.small + Flow { + visible: columnLayout.folded + spacing: -Kirigami.Units.iconSizes.small / 2 + Repeater { + model: authorList + delegate: Kirigami.Avatar { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small - name: author.displayName - source: author.avatarMediaId ? ("image://mxc/" + author.avatarMediaId) : "" - color: author.color - - Component { - id: userDetailDialog - - UserDetailDialog {} - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {room: currentRoom, user: author.object, displayName: author.displayName, avatarMediaId: author.avatarMediaId, avatarUrl: author.avatarUrl}).open() + name: modelData.displayName + source: modelData.avatarMediaId ? ("image://mxc/" + modelData.avatarMediaId) : "" + color: modelData.color + } } } - QQC2.Label { - id: label - Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - wrapMode: Text.WordWrap + visible: columnLayout.folded + + text: aggregateDisplay + elide: Qt.ElideRight textFormat: Text.RichText - text: `${model.authorDisplayName} ${aggregateDisplay}` + wrapMode: Text.WordWrap onLinkActivated: RoomManager.openResource(link) } + Item { + Layout.fillWidth: true + visible: !columnLayout.folded + } + QQC2.ToolButton { + icon.name: (!columnLayout.folded ? "go-up" : "go-down") + icon.width: Kirigami.Units.iconSizes.small + icon.height: Kirigami.Units.iconSizes.small + + onClicked: { + columnLayout.toggleFolded() + } + } + } + Repeater { + id: stateEventRepeater + model: stateEvents + delegate: StateComponent { + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0) + Layout.rightMargin: Kirigami.Units.largeSpacing + visible: !columnLayout.folded || stateEventRepeater.count === 1 + + name: modelData.author.displayName + avatar: modelData.author.avatarMediaId ? ("image://mxc/" + modelData.author.avatarMediaId) : "" + color: modelData.author.color + text: `${modelData.authorDisplayName} ${modelData.text}` + + onAvatarClicked: RoomManager.openResource("https://matrix.to/#/" + modelData.author.id) + onLinkClicked: RoomManager.openResource(link) + } + } + + function toggleFolded() { + folded = !folded + foldedChanged() } } } diff --git a/src/res.qrc b/src/res.qrc index 05dcddac0..fdcc70989 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -46,6 +46,7 @@ qml/Component/Timeline/ReadMarkerDelegate.qml qml/Component/Timeline/PollDelegate.qml qml/Component/Timeline/MimeComponent.qml + qml/Component/Timeline/StateComponent.qml qml/Component/Login/LoginStep.qml qml/Component/Login/Login.qml qml/Component/Login/Password.qml