diff --git a/CMakeLists.txt b/CMakeLists.txt index fc04e3a0..6b4bf52f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -603,7 +603,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG 03bb6fbd665260faec0148b5bb0bfe484e88581a + GIT_TAG 188ecb899744e55842c1debaa4597cdc5184be8a ) set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") diff --git a/im.nheko.Nheko.yaml b/im.nheko.Nheko.yaml index 917980a4..a2141baa 100644 --- a/im.nheko.Nheko.yaml +++ b/im.nheko.Nheko.yaml @@ -223,7 +223,7 @@ modules: buildsystem: cmake-ninja name: mtxclient sources: - - commit: 03bb6fbd665260faec0148b5bb0bfe484e88581a + - commit: 188ecb899744e55842c1debaa4597cdc5184be8a #tag: v0.9.2 type: git url: https://github.com/Nheko-Reborn/mtxclient.git diff --git a/resources/qml/Completer.qml b/resources/qml/Completer.qml index 965789bc..c6fea98e 100644 --- a/resources/qml/Completer.qml +++ b/resources/qml/Completer.qml @@ -42,6 +42,13 @@ Control { else return null; } + function currentUserid() { + if (popup.completerName == "user") { + return listView.itemAtIndex(currentIndex).modelData.userid; + } else { + return ""; + } + } function down() { if (bottomToTop) up_(); diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml index 4396f1d3..8b6af57a 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml @@ -114,6 +114,10 @@ Rectangle { function insertCompletion(completion) { messageInput.remove(completerTriggeredAt, cursorPosition); messageInput.insert(cursorPosition, completion); + let userid = completer.currentUserid(); + if (userid) { + room.input.addMention(userid, completion); + } } function openCompleter(pos, type) { if (popup.opened) @@ -176,10 +180,17 @@ Rectangle { } else if (event.matches(StandardKey.InsertParagraphSeparator)) { if (popup.opened) { var currentCompletion = completer.currentCompletion(); + let userid = completer.currentUserid(); + completer.completerName = ""; popup.close(); + if (currentCompletion) { messageInput.insertCompletion(currentCompletion); + if (userid) { + console.log(userid); + room.input.addMention(userid, currentCompletion); + } event.accepted = true; return; } diff --git a/resources/qml/MessageInputWarning.qml b/resources/qml/MessageInputWarning.qml index 4d5578b3..82658f58 100644 --- a/resources/qml/MessageInputWarning.qml +++ b/resources/qml/MessageInputWarning.qml @@ -12,6 +12,9 @@ Rectangle { property color bubbleColor: Nheko.theme.error required property string text + property bool showRemove: false + + signal removeClicked(); Layout.fillWidth: true color: palette.window // required to hide the timeline behind this warning @@ -35,10 +38,30 @@ Rectangle { id: warningDisplay anchors.left: parent.left + anchors.right: parent.right anchors.margins: Nheko.paddingSmall + anchors.rightMargin: warningRoot.showRemove ? (Nheko.paddingSmall*3 + removeButton.width) : Nheko.paddingSmall anchors.verticalCenter: parent.verticalCenter text: warningRoot.text textFormat: Text.PlainText } + + ImageButton { + id: removeButton + + visible: warningRoot.showRemove + + anchors.right: parent.right + anchors.margins: Nheko.paddingSmall + anchors.verticalCenter: parent.verticalCenter + + image: ":/icons/icons/ui/dismiss.svg" + hoverEnabled: true + ToolTip.visible: hovered + ToolTip.text: qsTr("Don't mention them in this message") + onClicked: { + warningRoot.removeClicked(); + } + } } } diff --git a/resources/qml/TimelineDefaultMessageStyle.qml b/resources/qml/TimelineDefaultMessageStyle.qml index 55c62b02..34808323 100644 --- a/resources/qml/TimelineDefaultMessageStyle.qml +++ b/resources/qml/TimelineDefaultMessageStyle.qml @@ -128,18 +128,6 @@ TimelineEvent { } } }, - Rectangle { - anchors.top: gridContainer.top - anchors.left: gridContainer.left - anchors.topMargin: -2 - anchors.leftMargin: -2 + (stateEventSpacing.visible ? (stateEventSpacing.width + gridContainer.spacing) : 0) - color: "transparent" - border.color: Nheko.theme.red - border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0 - radius: 4 - height: contentColumn.implicitHeight + 4 - width: contentColumn.implicitWidth + 4 + (wrapper.threadId ? (4 + gridContainer.spacing) : 0) - }, Row { id: gridContainer @@ -292,6 +280,18 @@ TimelineEvent { TapHandler { onDoubleTapped: wrapper.room.reply = wrapper.eventId } + }, + Rectangle { + anchors.top: gridContainer.top + anchors.left: gridContainer.left + anchors.topMargin: -2 + anchors.leftMargin: -2 + (stateEventSpacing.visible ? (stateEventSpacing.width + gridContainer.spacing) : 0) + color: "transparent" + border.color: Nheko.theme.red + border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0 + radius: 4 + height: contentColumn.implicitHeight + 4 + width: contentColumn.implicitWidth + 4 + (wrapper.threadId ? (4 + gridContainer.spacing) : 0) }, TimelineMetadata { id: metadata diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 7bad53c4..085ca073 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -148,9 +148,16 @@ Item { } UploadBox { } - MessageInputWarning { - text: qsTr("You are about to notify the whole room") - visible: (room && room.permissions.canPingRoom() && room.input.containsAtRoom) + Repeater { + model: room ? room.input.mentions : null + + MessageInputWarning { + required property string modelData + bubbleColor: modelData == "@room" ? Nheko.theme.error : Nheko.theme.orange + text: modelData == "@room" ? qsTr("You are about to notify the whole room") : qsTr("You will be mentioning %1").arg(modelData) + showRemove: true + onRemoveClicked: room.input.removeMention(modelData); + } } MessageInputWarning { text: qsTr("The command /%1 is not recognized and will be sent as part of your message").arg(room ? room.input.currentCommand : "") diff --git a/resources/qml/dialogs/RoomSettingsDialog.qml b/resources/qml/dialogs/RoomSettingsDialog.qml index 9276a9d3..8e127567 100644 --- a/resources/qml/dialogs/RoomSettingsDialog.qml +++ b/resources/qml/dialogs/RoomSettingsDialog.qml @@ -273,7 +273,7 @@ ApplicationWindow { ComboBox { model: [qsTr("Muted"), qsTr("Mentions only"), qsTr("All messages")] currentIndex: roomSettings.notifications - onActivated: { + onActivated: (index) => { roomSettings.changeNotifications(index); } Layout.fillWidth: true diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index a75b7ef5..a43a190c 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -198,9 +198,10 @@ ChatPage::ChatPage(QSharedPointer userSettings, QObject *parent) if (eventInDb) { if (auto newRules = std::get_if>( - &*eventInDb)) + &*eventInDb)) { pushrules = std::make_unique(newRules->content.global); + } } } if (pushrules) { diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp index ca730a75..a43d62c6 100644 --- a/src/EventAccessors.cpp +++ b/src/EventAccessors.cpp @@ -241,6 +241,18 @@ struct EventRelations } }; +struct EventMentions +{ + template + std::optional operator()(const mtx::events::Event &e) + { + if constexpr (requires { T::mentions; }) { + return e.content.mentions; + } + return std::nullopt; + } +}; + struct SetEventRelations { mtx::common::Relations new_relations; @@ -447,6 +459,11 @@ mtx::accessors::relations(const mtx::events::collections::TimelineEvents &event) { return std::visit(EventRelations{}, event); } +std::optional +mtx::accessors::mentions(const mtx::events::collections::TimelineEvents &event) +{ + return std::visit(EventMentions{}, event); +} void mtx::accessors::set_relations(mtx::events::collections::TimelineEvents &event, diff --git a/src/EventAccessors.h b/src/EventAccessors.h index 4128f681..3651e941 100644 --- a/src/EventAccessors.h +++ b/src/EventAccessors.h @@ -107,6 +107,8 @@ std::string mimetype(const mtx::events::collections::TimelineEvents &event); const mtx::common::Relations & relations(const mtx::events::collections::TimelineEvents &event); +std::optional +mentions(const mtx::events::collections::TimelineEvents &event); void set_relations(mtx::events::collections::TimelineEvents &event, mtx::common::Relations relations); std::string diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp index 62d38cf5..f8b57b81 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp @@ -179,31 +179,74 @@ InputBar::insertMimeData(const QMimeData *md) } void -InputBar::updateTextContentProperties(const QString &t) +InputBar::addMention(QString mention, QString text) { - // check for @room - bool roomMention = false; + if (!mentions_.contains(mention)) { + mentions_.push_back(mention); + mentionTexts_.push_back(text); - if (t.size() > 4) { - QTextBoundaryFinder finder(QTextBoundaryFinder::BoundaryType::Word, t); + emit mentionsChanged(); + if (mention == u"@room") { + this->containsAtRoom_ = true; + } + } +} - finder.toStart(); - do { - auto start = finder.position(); - finder.toNextBoundary(); - auto end = finder.position(); - if (start > 0 && end - start >= 4 && - t.mid(start, end - start) == QLatin1String("room") && - t.at(start - 1) == QChar('@')) { - roomMention = true; - break; +void +InputBar::removeMention(QString mention) +{ + if (auto idx = mentions_.indexOf(mention); idx != -1) { + mentions_.removeAt(idx); + mentionTexts_.removeAt(idx); + emit mentionsChanged(); + if (mention == u"@room") { + this->containsAtRoom_ = false; + } + } +} + +void +InputBar::updateTextContentProperties(const QString &t, bool charDeleted) +{ + auto containsRoomMention = [](QStringView text) { + // check for @room + bool roomMention = false; + if (text.size() > 4) { + QTextBoundaryFinder finder(QTextBoundaryFinder::BoundaryType::Word, text); + + finder.toStart(); + do { + auto start = finder.position(); + finder.toNextBoundary(); + auto end = finder.position(); + if (start > 0 && end - start >= 4 && + text.mid(start, end - start) == QStringView(u"room") && + text.at(start - 1) == QChar('@')) { + roomMention = true; + break; + } + } while (finder.position() < text.size()); + } + return roomMention; + }; + + if (charDeleted) { + for (qsizetype idx = 0; idx < mentions_.size();) { + if (!t.contains(mentionTexts_.at(idx))) { + removeMention(mentions_.at(idx)); + } else { + idx++; } - } while (finder.position() < t.size()); + } } + auto roomMention = containsRoomMention(t); + if (roomMention != this->containsAtRoom_) { - this->containsAtRoom_ = roomMention; - emit containsAtRoomChanged(); + if (roomMention) + addMention(QStringLiteral(u"@room"), QStringLiteral(u"@room")); + else + removeMention(QStringLiteral(u"@room")); } // check for invalid commands @@ -280,7 +323,7 @@ InputBar::setText(const QString &newText) if (history_.size() == INPUT_HISTORY_SIZE) history_.pop_back(); - updateTextContentProperties(QLatin1String("")); + updateTextContentProperties(newText, true); emit textChanged(newText); } void @@ -294,14 +337,15 @@ InputBar::updateState(int selectionStart_, else startTyping(); - if (text_ != text()) { + auto oldText = text(); + if (text_ != oldText) { if (history_.empty()) history_.push_front(text_); else history_.front() = text_; history_index_ = 0; - updateTextContentProperties(text_); + updateTextContentProperties(text_, text_.size() < oldText.size()); // disabled, as it moves the cursor to the end // emit textChanged(text_); } @@ -452,6 +496,24 @@ InputBar::generateRelations() const return relations; } +mtx::common::Mentions +InputBar::generateMentions() +{ + std::vector userMentions; + for (const auto &m : mentions_) + if (m != u"@room") + userMentions.push_back(m.toStdString()); + auto mention = mtx::common::Mentions{ + .user_ids = userMentions, + .room = containsAtRoom_, + }; + + // this->containsAtRoom_ = false; + // this->mentions_.clear(); + // this->mentionTexts_.clear(); + return mention; +} + void InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbowify) { @@ -484,6 +546,7 @@ InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbow text.format = "org.matrix.custom.html"; } + text.mentions = generateMentions(); text.relations = generateRelations(); if (!room->reply().isEmpty() && room->thread().isEmpty() && room->edit().isEmpty()) { auto related = room->relatedInfo(room->reply()); @@ -540,6 +603,7 @@ InputBar::emote(const QString &msg, bool rainbowify) emote.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); } + emote.mentions = generateMentions(); emote.relations = generateRelations(); room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); @@ -560,6 +624,7 @@ InputBar::notice(const QString &msg, bool rainbowify) notice.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); } + notice.mentions = generateMentions(); notice.relations = generateRelations(); room->sendMessageEvent(notice, mtx::events::EventType::RoomMessage); @@ -582,6 +647,7 @@ InputBar::confetti(const QString &body, bool rainbowify) confetti.body = replaceMatrixToMarkdownLink(body.trimmed()).toStdString(); } + confetti.mentions = generateMentions(); confetti.relations = generateRelations(); room->sendMessageEvent(confetti, mtx::events::EventType::RoomMessage); @@ -606,6 +672,7 @@ InputBar::rainfall(const QString &body) rain.body = replaceMatrixToMarkdownLink(body.trimmed()).toStdString(); } + rain.mentions = generateMentions(); rain.relations = generateRelations(); room->sendMessageEvent(rain, mtx::events::EventType::RoomMessage); @@ -630,6 +697,7 @@ InputBar::customMsgtype(const QString &msgtype, const QString &body) msg.body = replaceMatrixToMarkdownLink(body.trimmed()).toStdString(); } + msg.mentions = generateMentions(); msg.relations = generateRelations(); room->sendMessageEvent(msg, mtx::events::EventType::RoomMessage); @@ -673,6 +741,7 @@ InputBar::image(const QString &filename, image.info.thumbnail_info.mimetype = "image/png"; } + image.mentions = generateMentions(); image.relations = generateRelations(); room->sendMessageEvent(image, mtx::events::EventType::RoomMessage); @@ -695,6 +764,7 @@ InputBar::file(const QString &filename, else file.url = url.toStdString(); + file.mentions = generateMentions(); file.relations = generateRelations(); room->sendMessageEvent(file, mtx::events::EventType::RoomMessage); @@ -722,6 +792,7 @@ InputBar::audio(const QString &filename, else audio.url = url.toStdString(); + audio.mentions = generateMentions(); audio.relations = generateRelations(); room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); @@ -771,6 +842,7 @@ InputBar::video(const QString &filename, video.info.thumbnail_info.mimetype = "image/png"; } + video.mentions = generateMentions(); video.relations = generateRelations(); room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); @@ -825,6 +897,7 @@ InputBar::sticker(QStringList descriptor) sticker.info.thumbnail_info.h = sticker.info.h; sticker.info.thumbnail_info.w = sticker.info.w; + sticker.mentions = generateMentions(); sticker.relations = generateRelations(); room->sendMessageEvent(sticker, mtx::events::EventType::Sticker); diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index fbf08343..c38de662 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -158,12 +158,12 @@ class InputBar final : public QObject { Q_OBJECT Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged) - Q_PROPERTY(bool containsAtRoom READ containsAtRoom NOTIFY containsAtRoomChanged) Q_PROPERTY( bool containsInvalidCommand READ containsInvalidCommand NOTIFY containsInvalidCommandChanged) Q_PROPERTY(bool containsIncompleteCommand READ containsIncompleteCommand NOTIFY containsIncompleteCommandChanged) Q_PROPERTY(QString currentCommand READ currentCommand NOTIFY currentCommandChanged) + Q_PROPERTY(QStringList mentions READ mentions NOTIFY mentionsChanged) Q_PROPERTY(QString text READ text NOTIFY textChanged) Q_PROPERTY(QVariantList uploads READ uploads NOTIFY uploadsChanged) @@ -188,7 +188,37 @@ public slots: QString nextText(); void setText(const QString &newText); - [[nodiscard]] bool containsAtRoom() const { return containsAtRoom_; } + [[nodiscard]] QStringList mentions() const { return mentions_; } + void addMention(QString m, QString text); + void removeMention(QString m); + + void storeForEdit() + { + textBeforeEdit = text(); + mentionsBefore = mentions_; + mentionTextsBefore = mentionTexts_; + emit mentionsChanged(); + } + void restoreAfterEdit() + { + mentions_ = mentionsBefore; + mentionTexts_ = mentionTextsBefore; + mentionsBefore.clear(); + mentionTextsBefore.clear(); + setText(textBeforeEdit); + textBeforeEdit.clear(); + emit mentionsChanged(); + } + void replaceMentions(QStringList newMentions, QStringList newMentionTexts) + { + if (newMentions.size() != newMentionTexts.size()) + return; + + mentions_ = newMentions; + mentionTexts_ = newMentionTexts; + emit mentionsChanged(); + } + bool containsInvalidCommand() const { return containsInvalidCommand_; } bool containsIncompleteCommand() const { return containsIncompleteCommand_; } QString currentCommand() const { return currentCommand_; } @@ -218,8 +248,8 @@ private slots: signals: void textChanged(QString newText); void uploadingChanged(bool value); - void containsAtRoomChanged(); void containsInvalidCommandChanged(); + void mentionsChanged(); void containsIncompleteCommandChanged(); void currentCommandChanged(); void uploadsChanged(); @@ -269,6 +299,7 @@ private: QPair getCommandAndArgs() const { return getCommandAndArgs(text()); } QPair getCommandAndArgs(const QString ¤tText) const; mtx::common::Relations generateRelations() const; + mtx::common::Mentions generateMentions(); void startUploadFromPath(const QString &path); void startUploadFromMimeData(const QMimeData &source, const QString &format); @@ -281,7 +312,7 @@ private: } } - void updateTextContentProperties(const QString &t); + void updateTextContentProperties(const QString &t, bool textDeleted = false); void toggleIgnore(const QString &user, const bool ignored); @@ -296,6 +327,10 @@ private: bool containsInvalidCommand_ = false; bool containsIncompleteCommand_ = false; QString currentCommand_; + QStringList mentions_, mentionTexts_; + // store stuff during edits + QStringList mentionsBefore, mentionTextsBefore; + QString textBeforeEdit; using UploadHandle = std::unique_ptr; std::vector unconfirmedUploads; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 05a3c45c..e7fb31f5 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -3069,9 +3069,7 @@ TimelineModel::setEdit(const QString &newEdit) } if (edit_.isEmpty()) { - this->textBeforeEdit = input()->text(); - this->replyBeforeEdit = reply_; - nhlog::ui()->debug("Stored: {}", textBeforeEdit.toStdString()); + input()->storeForEdit(); } auto quoted = [](QString in) { return in.replace("[", "\\[").replace("]", "\\]"); }; @@ -3083,6 +3081,24 @@ TimelineModel::setEdit(const QString &newEdit) setReply(QString::fromStdString(mtx::accessors::relations(e).reply_to().value_or(""))); setThread(QString::fromStdString(mtx::accessors::relations(e).thread().value_or(""))); + auto mentionsList = mtx::accessors::mentions(e); + QStringList mentions, mentionTexts; + if (mentionsList) { + if (mentionsList->room) { + mentions.append(QStringLiteral(u"@room")); + mentionTexts.append(QStringLiteral(u"@room")); + } + + for (const auto &user : mentionsList->user_ids) { + auto userid = QString::fromStdString(user); + mentions.append(userid); + mentionTexts.append( + QStringLiteral("[%1](https://matrix.to/#/%2)") + .arg(displayName(userid).replace("[", "\\[").replace("]", "\\]"), + QString(QUrl::toPercentEncoding(userid)))); + } + } + auto msgType = mtx::accessors::msg_type(e); if (msgType == mtx::events::MessageType::Text || msgType == mtx::events::MessageType::Notice || @@ -3130,6 +3146,7 @@ TimelineModel::setEdit(const QString &newEdit) } else { input()->setText(QLatin1String("")); } + input()->replaceMentions(std::move(mentions), std::move(mentionTexts)); edit_ = newEdit; } else { @@ -3148,9 +3165,7 @@ TimelineModel::resetEdit() if (!edit_.isEmpty()) { edit_ = QLatin1String(""); emit editChanged(edit_); - nhlog::ui()->debug("Restoring: {}", textBeforeEdit.toStdString()); - input()->setText(textBeforeEdit); - textBeforeEdit.clear(); + input()->restoreAfterEdit(); if (replyBeforeEdit.isEmpty()) resetReply(); else diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index 08c776f8..c7f3ebb6 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -529,7 +529,7 @@ private: QString currentId, currentReadId; QString reply_, edit_, thread_; - QString textBeforeEdit, replyBeforeEdit; + QString replyBeforeEdit; QStringList typingUsers_; TimelineViewManager *manager_;