From 75b112f0c830dd3a87d1c428a8ad5a8449b8e924 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 11 Dec 2021 06:10:41 +0100 Subject: [PATCH] Support pinned messages fixes #519 --- resources/icons/ui/pin-off.svg | 1 + resources/icons/ui/pin.svg | 1 + resources/qml/MessageView.qml | 7 + resources/qml/TopBar.qml | 137 ++++++++++++++++++-- resources/qml/delegates/MessageDelegate.qml | 48 +++++++ resources/qml/delegates/Reply.qml | 4 +- resources/res.qrc | 2 + src/UserSettingsPage.cpp | 10 ++ src/UserSettingsPage.h | 6 + src/timeline/TimelineModel.cpp | 87 ++++++++++++- src/timeline/TimelineModel.h | 9 ++ 11 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 resources/icons/ui/pin-off.svg create mode 100644 resources/icons/ui/pin.svg diff --git a/resources/icons/ui/pin-off.svg b/resources/icons/ui/pin-off.svg new file mode 100644 index 00000000..598610ad --- /dev/null +++ b/resources/icons/ui/pin-off.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/ui/pin.svg b/resources/icons/ui/pin.svg new file mode 100644 index 00000000..76d1124d --- /dev/null +++ b/resources/icons/ui/pin.svg @@ -0,0 +1 @@ + diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index 2acdf839..375b4017 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -582,6 +582,13 @@ ScrollView { onTriggered: room.editAction(messageContextMenu.eventId) } + Platform.MenuItem { + visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false) + enabled: visible + text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin") + onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId) + } + Platform.MenuItem { text: qsTr("Read receip&ts") onTriggered: room.showReadReceipts(messageContextMenu.eventId) diff --git a/resources/qml/TopBar.qml b/resources/qml/TopBar.qml index 53acdc39..ef12eaf7 100644 --- a/resources/qml/TopBar.qml +++ b/resources/qml/TopBar.qml @@ -8,6 +8,8 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.2 import im.nheko 1.0 +import "./delegates" + Rectangle { id: topBar @@ -28,6 +30,19 @@ Rectangle { TapHandler { onSingleTapped: { + if (eventPoint.position.y > topBar.height - pinnedMessages.height) { + eventPoint.accepted = true + return; + } + if (showBackButton && eventPoint.position.x < Nheko.paddingMedium + backToRoomsButton.width) { + eventPoint.accepted = true + return; + } + if (eventPoint.position.x > topBar.width - Nheko.paddingMedium - roomOptionsButton.width) { + eventPoint.accepted = true + return; + } + if (room) { let p = topBar.mapToItem(roomTopicC, eventPoint.position.x, eventPoint.position.y); let link = roomTopicC.linkAt(p.x, p.y); @@ -46,11 +61,11 @@ Rectangle { HoverHandler { grabPermissions: PointerHandler.TakeOverForbidden | PointerHandler.CanTakeOverFromAnything - //cursorShape: Qt.PointingHandCursor } CursorShape { anchors.fill: parent + anchors.bottomMargin: pinnedMessages.height cursorShape: Qt.PointingHandCursor } @@ -61,6 +76,8 @@ Rectangle { anchors.right: parent.right anchors.margins: Nheko.paddingMedium anchors.verticalCenter: parent.verticalCenter + columnSpacing: Nheko.paddingSmall + rowSpacing: Nheko.paddingSmall ImageButton { id: backToRoomsButton @@ -129,24 +146,54 @@ Rectangle { trust: trustlevel ToolTip.text: { if (!encrypted) - return qsTr("This room is not encrypted!"); + return qsTr("This room is not encrypted!"); switch (trust) { - case Crypto.Verified: + case Crypto.Verified: return qsTr("This room contains only verified devices."); - case Crypto.TOFU: + case Crypto.TOFU: return qsTr("This room contains verified devices and devices which have never changed their master key."); - default: + default: return qsTr("This room contains unverified devices!"); } } } + ImageButton { + id: pinButton + + property bool pinsShown: !Settings.hiddenPins.includes(roomId) + + visible: !!room && room.pinnedMessages.length > 0 + Layout.column: 4 + Layout.row: 0 + Layout.rowSpan: 2 + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium + Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium + image: pinsShown ? ":/icons/icons/ui/pin.svg" : ":/icons/icons/ui/pin-off.svg" + ToolTip.visible: hovered + ToolTip.text: qsTr("Show or hide pinned messages") + onClicked: { + var ps = Settings.hiddenPins; + if (pinsShown) { + ps.push(roomId); + } else { + const index = ps.indexOf(roomId); + if (index > -1) { + ps.splice(index, 1); + } + } + Settings.hiddenPins = ps; + } + + } + ImageButton { id: roomOptionsButton visible: !!room - Layout.column: 4 + Layout.column: 5 Layout.row: 0 Layout.rowSpan: 2 Layout.alignment: Qt.AlignVCenter @@ -185,11 +232,79 @@ Rectangle { } - } + ScrollView { + id: pinnedMessages - CursorShape { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - } + Layout.row: 2 + Layout.column: 2 + Layout.columnSpan: 1 + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, Nheko.avatarSize * 4) + + visible: !!room && room.pinnedMessages.length > 0 && !Settings.hiddenPins.includes(roomId) + clip: true + + palette: Nheko.colors + ScrollBar.horizontal.visible: false + + ListView { + + spacing: Nheko.paddingSmall + model: room ? room.pinnedMessages : undefined + delegate: RowLayout { + required property string modelData + + width: ListView.view.width + height: implicitHeight + + Reply { + property var e: room ? room.getDump(modelData, "") : {} + Layout.fillWidth: true + Layout.preferredHeight: height + + userColor: TimelineManager.userColor(e.userId, Nheko.colors.window) + blurhash: e.blurhash ?? "" + body: e.body ?? "" + formattedBody: e.formattedBody ?? "" + eventId: e.eventId ?? "" + filename: e.filename ?? "" + filesize: e.filesize ?? "" + proportionalHeight: e.proportionalHeight ?? 1 + type: e.type ?? MtxEvent.UnknownMessage + typeString: e.typeString ?? "" + url: e.url ?? "" + originalWidth: e.originalWidth ?? 0 + isOnlyEmoji: e.isOnlyEmoji ?? false + userId: e.userId ?? "" + userName: e.userName ?? "" + encryptionError: e.encryptionError ?? "" + } + + ImageButton { + id: deletePinButton + + Layout.preferredHeight: 16 + Layout.preferredWidth: 16 + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + visible: room.permissions.canChange(MtxEvent.PinnedEvents) + + hoverEnabled: true + image: ":/icons/icons/ui/dismiss.svg" + ToolTip.visible: hovered + ToolTip.text: qsTr("Unpin") + + onClicked: room.unpin(modelData) + } + } + + + ScrollHelper { + flickable: parent + anchors.fill: parent + enabled: !Settings.mobileMode + } + } + } + } } diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml index dc88cf24..74f7d011 100644 --- a/resources/qml/delegates/MessageDelegate.qml +++ b/resources/qml/delegates/MessageDelegate.qml @@ -239,6 +239,54 @@ Item { } + DelegateChoice { + roleValue: MtxEvent.PinnedEvents + + NoticeMessage { + body: formatted + isOnlyEmoji: false + isReply: d.isReply + formatted: qsTr("%1 changed the pinned messages.").arg(d.userName) + } + + } + + DelegateChoice { + roleValue: MtxEvent.ImagePackInRoom + + NoticeMessage { + body: formatted + isOnlyEmoji: false + isReply: d.isReply + formatted: qsTr("%1 changed the stickers and emotes in this room.").arg(d.userName) + } + + } + + DelegateChoice { + roleValue: MtxEvent.CanonicalAlias + + NoticeMessage { + body: formatted + isOnlyEmoji: false + isReply: d.isReply + formatted: qsTr("%1 changed the addresses for this room.").arg(d.userName) + } + + } + + DelegateChoice { + roleValue: MtxEvent.SpaceParent + + NoticeMessage { + body: formatted + isOnlyEmoji: false + isReply: d.isReply + formatted: qsTr("%1 changed the parent spaces for this room.").arg(d.userName) + } + + } + DelegateChoice { roleValue: MtxEvent.RoomCreate diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml index 4e973c3d..547044f3 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml @@ -60,7 +60,7 @@ Item { TapHandler { acceptedButtons: Qt.LeftButton - onSingleTapped: chat.model.showEvent(r.eventId) + onSingleTapped: room.showEvent(r.eventId) gesturePolicy: TapHandler.ReleaseWithinBounds } @@ -79,7 +79,7 @@ Item { textFormat: Text.RichText TapHandler { - onSingleTapped: chat.model.openUserProfile(userId) + onSingleTapped: room.openUserProfile(userId) gesturePolicy: TapHandler.ReleaseWithinBounds } diff --git a/resources/res.qrc b/resources/res.qrc index 2ab60e3a..67c35351 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -23,6 +23,8 @@ icons/ui/pause-symbol.svg icons/ui/people.svg icons/ui/picture-in-picture.svg + icons/ui/pin-off.svg + icons/ui/pin.svg icons/ui/place-call.svg icons/ui/play-sign.svg icons/ui/power-off.svg diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp index 5ba1dcdc..eae31b71 100644 --- a/src/UserSettingsPage.cpp +++ b/src/UserSettingsPage.cpp @@ -117,6 +117,7 @@ UserSettings::load(std::optional profile) userId_ = settings.value(prefix + "auth/user_id", "").toString(); deviceId_ = settings.value(prefix + "auth/device_id", "").toString(); hiddenTags_ = settings.value(prefix + "user/hidden_tags", QStringList{}).toStringList(); + hiddenPins_ = settings.value(prefix + "user/hidden_pins", QStringList{}).toStringList(); collapsedSpaces_.clear(); for (const auto &e : @@ -200,6 +201,14 @@ UserSettings::setHiddenTags(QStringList hiddenTags) save(); } +void +UserSettings::setHiddenPins(QStringList hiddenTags) +{ + hiddenPins_ = hiddenTags; + save(); + emit hiddenPinsChanged(); +} + void UserSettings::setCollapsedSpaces(QList spaces) { @@ -707,6 +716,7 @@ UserSettings::save() onlyShareKeysWithVerifiedUsers_); settings.setValue(prefix + "user/online_key_backup", useOnlineKeyBackup_); settings.setValue(prefix + "user/hidden_tags", hiddenTags_); + settings.setValue(prefix + "user/hidden_pins", hiddenPins_); QVariantList v; for (const auto &e : collapsedSpaces_) diff --git a/src/UserSettingsPage.h b/src/UserSettingsPage.h index c47844cb..ab73414e 100644 --- a/src/UserSettingsPage.h +++ b/src/UserSettingsPage.h @@ -105,6 +105,8 @@ class UserSettings : public QObject setDisableCertificateValidation NOTIFY disableCertificateValidationChanged) Q_PROPERTY(bool useIdenticon READ useIdenticon WRITE setUseIdenticon NOTIFY useIdenticonChanged) + Q_PROPERTY(QStringList hiddenPins READ hiddenPins WRITE setHiddenPins NOTIFY hiddenPinsChanged) + UserSettings(); public: @@ -171,6 +173,7 @@ public: void setHomeserver(QString homeserver); void setDisableCertificateValidation(bool disabled); void setHiddenTags(QStringList hiddenTags); + void setHiddenPins(QStringList hiddenTags); void setUseIdenticon(bool state); void setCollapsedSpaces(QList spaces); @@ -228,6 +231,7 @@ public: QString homeserver() const { return homeserver_; } bool disableCertificateValidation() const { return disableCertificateValidation_; } QStringList hiddenTags() const { return hiddenTags_; } + QStringList hiddenPins() const { return hiddenPins_; } bool useIdenticon() const { return useIdenticon_ && JdenticonProvider::isAvailable(); } QList collapsedSpaces() const { return collapsedSpaces_; } @@ -278,6 +282,7 @@ signals: void homeserverChanged(QString homeserver); void disableCertificateValidationChanged(bool disabled); void useIdenticonChanged(bool state); + void hiddenPinsChanged(); private: // Default to system theme if QT_QPA_PLATFORMTHEME var is set. @@ -331,6 +336,7 @@ private: QString deviceId_; QString homeserver_; QStringList hiddenTags_; + QStringList hiddenPins_; QList collapsedSpaces_; bool useIdenticon_; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 5a5f4850..b9941dfa 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -93,6 +93,10 @@ struct RoomEventType return qml_mtx_events::EventType::Sticker; case EventType::Tag: return qml_mtx_events::EventType::Tag; + case EventType::SpaceParent: + return qml_mtx_events::EventType::SpaceParent; + case EventType::SpaceChild: + return qml_mtx_events::EventType::SpaceChild; case EventType::Unsupported: return qml_mtx_events::EventType::Unsupported; default: @@ -286,6 +290,12 @@ qml_mtx_events::fromRoomEventType(qml_mtx_events::EventType t) // m.tag case qml_mtx_events::Tag: return mtx::events::EventType::Tag; + // m.space.parent + case qml_mtx_events::SpaceParent: + return mtx::events::EventType::SpaceParent; + // m.space.child + case qml_mtx_events::SpaceChild: + return mtx::events::EventType::SpaceChild; /// m.room.message case qml_mtx_events::AudioMessage: case qml_mtx_events::EmoteMessage: @@ -808,7 +818,9 @@ TimelineModel::syncState(const mtx::responses::State &s) emit roomNameChanged(); else if (std::holds_alternative>(e)) emit roomTopicChanged(); - else if (std::holds_alternative>(e)) { + else if (std::holds_alternative>(e)) + emit pinnedMessagesChanged(); + else if (std::holds_alternative>(e)) { permissions_.invalidate(); emit permissionsChanged(); } else if (std::holds_alternative>(e)) { @@ -870,6 +882,8 @@ TimelineModel::addEvents(const mtx::responses::Timeline &timeline) emit roomNameChanged(); else if (std::holds_alternative>(e)) emit roomTopicChanged(); + else if (std::holds_alternative>(e)) + emit pinnedMessagesChanged(); else if (std::holds_alternative>(e)) { permissions_.invalidate(); emit permissionsChanged(); @@ -1084,6 +1098,60 @@ TimelineModel::replyAction(QString id) setReply(id); } +void +TimelineModel::unpin(QString id) +{ + auto pinned = + cache::client()->getStateEvent(room_id_.toStdString()); + + mtx::events::state::PinnedEvents content{}; + if (pinned) + content = pinned->content; + + auto idStr = id.toStdString(); + + for (auto it = content.pinned.begin(); it != content.pinned.end(); ++it) { + if (*it == idStr) { + content.pinned.erase(it); + break; + } + } + + http::client()->send_state_event( + room_id_.toStdString(), + content, + [idStr](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) + nhlog::net()->error("Failed to unpin {}: {}", idStr, *err); + else + nhlog::net()->debug("Unpinned {}", idStr); + }); +} + +void +TimelineModel::pin(QString id) +{ + auto pinned = + cache::client()->getStateEvent(room_id_.toStdString()); + + mtx::events::state::PinnedEvents content{}; + if (pinned) + content = pinned->content; + + auto idStr = id.toStdString(); + content.pinned.push_back(idStr); + + http::client()->send_state_event( + room_id_.toStdString(), + content, + [idStr](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) + nhlog::net()->error("Failed to pin {}: {}", idStr, *err); + else + nhlog::net()->debug("Pinned {}", idStr); + }); +} + void TimelineModel::editAction(QString id) { @@ -2108,6 +2176,23 @@ TimelineModel::roomTopic() const utils::linkifyMessage(QString::fromStdString(info[room_id_].topic).toHtmlEscaped())); } +QStringList +TimelineModel::pinnedMessages() const +{ + auto pinned = + cache::client()->getStateEvent(room_id_.toStdString()); + + if (!pinned || pinned->content.pinned.empty()) + return {}; + + QStringList list; + list.reserve(pinned->content.pinned.size()); + for (const auto &p : pinned->content.pinned) + list.push_back(QString::fromStdString(p)); + + return list; +} + crypto::Trust TimelineModel::trustlevel() const { diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index fe09af75..a06d4063 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -115,6 +115,10 @@ enum EventType ImagePackInAccountData, //! m.image_pack.rooms, currently im.ponies.emote_rooms ImagePackRooms, + // m.space.parent + SpaceParent, + // m.space.child + SpaceChild, }; Q_ENUM_NS(EventType) mtx::events::EventType fromRoomEventType(qml_mtx_events::EventType); @@ -172,6 +176,7 @@ class TimelineModel : public QAbstractListModel Q_PROPERTY(QString plainRoomName READ plainRoomName NOTIFY plainRoomNameChanged) Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY roomAvatarUrlChanged) Q_PROPERTY(QString roomTopic READ roomTopic NOTIFY roomTopicChanged) + Q_PROPERTY(QStringList pinnedMessages READ pinnedMessages NOTIFY pinnedMessagesChanged) Q_PROPERTY(int roomMemberCount READ roomMemberCount NOTIFY roomMemberCountChanged) Q_PROPERTY(bool isEncrypted READ isEncrypted NOTIFY encryptionChanged) Q_PROPERTY(bool isSpace READ isSpace CONSTANT) @@ -256,6 +261,8 @@ public: Q_INVOKABLE void openUserProfile(QString userid); Q_INVOKABLE void editAction(QString id); Q_INVOKABLE void replyAction(QString id); + Q_INVOKABLE void unpin(QString id); + Q_INVOKABLE void pin(QString id); Q_INVOKABLE void showReadReceipts(QString id); Q_INVOKABLE void redactEvent(QString id); Q_INVOKABLE int idToIndex(QString id) const; @@ -354,6 +361,7 @@ public slots: QString roomName() const; QString plainRoomName() const; QString roomTopic() const; + QStringList pinnedMessages() const; InputBar *input() { return &input_; } Permissions *permissions() { return &permissions_; } QString roomAvatarUrl() const; @@ -395,6 +403,7 @@ signals: void roomNameChanged(); void plainRoomNameChanged(); void roomTopicChanged(); + void pinnedMessagesChanged(); void roomAvatarUrlChanged(); void roomMemberCountChanged(); void isDirectChanged();