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();