diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml index 6029a31d..6ddbb32e 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml @@ -14,6 +14,8 @@ import im.nheko 1.0 Rectangle { id: inputBar + readonly property string text: messageInput.text + color: Nheko.colors.window Layout.fillWidth: true Layout.preferredHeight: row.implicitHeight diff --git a/resources/qml/MessageInputWarning.qml b/resources/qml/MessageInputWarning.qml new file mode 100644 index 00000000..9b0b0907 --- /dev/null +++ b/resources/qml/MessageInputWarning.qml @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import im.nheko 1.0 + +Rectangle { + id: warningRoot + + required property string text + property color bubbleColor: Nheko.theme.error + + implicitHeight: visible ? warningDisplay.implicitHeight + 4 * Nheko.paddingSmall : 0 + height: implicitHeight + Layout.fillWidth: true + color: Nheko.colors.window // required to hide the timeline behind this warning + + Rectangle { + id: warningRect + + visible: warningRoot.visible + // TODO: Qt.alpha() would make more sense but it wasn't working... + color: Qt.rgba(bubbleColor.r, bubbleColor.g, bubbleColor.b, 0.3) + border.width: 1 + border.color: bubbleColor + radius: 3 + anchors.fill: parent + anchors.margins: visible ? Nheko.paddingSmall : 0 + z: 3 + + Label { + id: warningDisplay + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Nheko.paddingSmall + color: Nheko.colors.text + text: warningRoot.text + textFormat: Text.PlainText + } + + } + +} diff --git a/resources/qml/NotificationWarning.qml b/resources/qml/NotificationWarning.qml deleted file mode 100644 index cc318843..00000000 --- a/resources/qml/NotificationWarning.qml +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: Nheko Contributors -// -// SPDX-License-Identifier: GPL-3.0-or-later - -import QtQuick 2.9 -import QtQuick.Controls 2.3 -import QtQuick.Layouts 1.2 -import im.nheko 1.0 - -Item { - implicitHeight: warningRect.visible ? warningDisplay.implicitHeight : 0 - height: implicitHeight - Layout.fillWidth: true - - Rectangle { - id: warningRect - - visible: (room && room.permissions.canPingRoom() && room.input.containsAtRoom) - color: Nheko.colors.base - anchors.fill: parent - z: 3 - - Label { - id: warningDisplay - - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.bottom: parent.bottom - color: Nheko.theme.red - text: qsTr("You are about to notify the whole room") - textFormat: Text.PlainText - } - - } - -} diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index c7b554b8..70347009 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -153,7 +153,20 @@ Item { UploadBox { } - NotificationWarning { + MessageInputWarning { + text: qsTr("You are about to notify the whole room") + visible: (room && room.permissions.canPingRoom() && room.input.containsAtRoom) + } + + MessageInputWarning { + text: qsTr("The command /%1 is not recognized and will be sent as part of your message").arg(room ? room.input.currentCommand : "") + visible: room ? room.input.containsInvalidCommand && !room.input.containsIncompleteCommand : false + } + + MessageInputWarning { + text: qsTr("/%1 looks like an incomplete command. To send it anyway, add a space to the end of your message.").arg(room ? room.input.currentCommand : "") + visible: room ? room.input.containsIncompleteCommand : false + bubbleColor: Nheko.theme.orange } ReplyPopup { diff --git a/resources/res.qrc b/resources/res.qrc index 9c7d0c87..faa90495 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -122,7 +122,7 @@ qml/ForwardCompleter.qml qml/SelfVerificationCheck.qml qml/TypingIndicator.qml - qml/NotificationWarning.qml + qml/MessageInputWarning.qml qml/components/AdaptiveLayout.qml qml/components/AdaptiveLayoutElement.qml qml/components/AvatarListTile.qml diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp index 7d964bb5..b27128e0 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp @@ -224,8 +224,9 @@ InputBar::insertMimeData(const QMimeData *md) } void -InputBar::updateAtRoom(const QString &t) +InputBar::updateTextContentProperties(const QString &t) { + // check for @room bool roomMention = false; if (t.size() > 4) { @@ -249,6 +250,61 @@ InputBar::updateAtRoom(const QString &t) this->containsAtRoom_ = roomMention; emit containsAtRoomChanged(); } + + // check for invalid commands + auto commandName = getCommandAndArgs(t).first; + static const QSet validCommands{QStringLiteral("me"), + QStringLiteral("react"), + QStringLiteral("join"), + QStringLiteral("knock"), + QStringLiteral("part"), + QStringLiteral("leave"), + QStringLiteral("invite"), + QStringLiteral("kick"), + QStringLiteral("ban"), + QStringLiteral("unban"), + QStringLiteral("redact"), + QStringLiteral("roomnick"), + QStringLiteral("shrug"), + QStringLiteral("fliptable"), + QStringLiteral("unfliptable"), + QStringLiteral("sovietflip"), + QStringLiteral("clear-timeline"), + QStringLiteral("reset-state"), + QStringLiteral("rotate-megolm-session"), + QStringLiteral("md"), + QStringLiteral("cmark"), + QStringLiteral("plain"), + QStringLiteral("rainbow"), + QStringLiteral("rainbowme"), + QStringLiteral("notice"), + QStringLiteral("rainbownotice"), + QStringLiteral("confetti"), + QStringLiteral("rainbowconfetti"), + QStringLiteral("goto"), + QStringLiteral("converttodm"), + QStringLiteral("converttoroom")}; + bool hasInvalidCommand = !commandName.isNull() && !validCommands.contains(commandName); + bool hasIncompleteCommand = hasInvalidCommand && '/' + commandName == t; + + bool signalsChanged{false}; + if (containsInvalidCommand_ != hasInvalidCommand) { + containsInvalidCommand_ = hasInvalidCommand; + signalsChanged = true; + } + if (containsIncompleteCommand_ != hasIncompleteCommand) { + containsIncompleteCommand_ = hasIncompleteCommand; + signalsChanged = true; + } + if (currentCommand_ != commandName) { + currentCommand_ = commandName; + signalsChanged = true; + } + if (signalsChanged) { + emit currentCommandChanged(); + emit containsInvalidCommandChanged(); + emit containsIncompleteCommandChanged(); + } } void @@ -263,7 +319,7 @@ InputBar::setText(const QString &newText) if (history_.size() == INPUT_HISTORY_SIZE) history_.pop_back(); - updateAtRoom(QLatin1String("")); + updateTextContentProperties(QLatin1String("")); emit textChanged(newText); } void @@ -284,7 +340,7 @@ InputBar::updateState(int selectionStart_, history_.front() = text_; history_index_ = 0; - updateAtRoom(text_); + updateTextContentProperties(text_); // disabled, as it moves the cursor to the end // emit textChanged(text_); } @@ -312,7 +368,7 @@ InputBar::previousText() else if (text().isEmpty()) history_index_--; - updateAtRoom(text()); + updateTextContentProperties(text()); return text(); } @@ -323,7 +379,7 @@ InputBar::nextText() if (history_index_ >= INPUT_HISTORY_SIZE) history_index_ = 0; - updateAtRoom(text()); + updateTextContentProperties(text()); return text(); } @@ -341,20 +397,12 @@ InputBar::send() auto wasEdit = !room->edit().isEmpty(); - if (text().startsWith('/')) { - int command_end = text().indexOf(QRegularExpression(QStringLiteral("\\s"))); - if (command_end == -1) - command_end = text().size(); - auto name = text().mid(1, command_end - 1); - auto args = text().mid(command_end + 1); - if (name.isEmpty() || name == QLatin1String("/")) { - message(args); - } else { - command(name, args); - } - } else { + auto [commandName, args] = getCommandAndArgs(); + updateTextContentProperties(text()); + if (containsIncompleteCommand_) + return; + if (commandName.isEmpty() || !command(commandName, args)) message(text()); - } if (!wasEdit) { history_.push_front(QLatin1String("")); @@ -716,6 +764,24 @@ InputBar::video(const QString &filename, room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); } +QPair +InputBar::getCommandAndArgs(const QString ¤tText) const +{ + if (!currentText.startsWith('/')) + return {{}, currentText}; + + int command_end = currentText.indexOf(QRegularExpression(QStringLiteral("\\s"))); + if (command_end == -1) + command_end = currentText.size(); + auto name = currentText.mid(1, command_end - 1); + auto args = currentText.mid(command_end + 1); + if (name.isEmpty() || name == QLatin1String("/")) { + return {{}, currentText}; + } else { + return {name, args}; + } +} + void InputBar::sticker(CombinedImagePackModel *model, int row) { @@ -741,7 +807,7 @@ InputBar::sticker(CombinedImagePackModel *model, int row) room->sendMessageEvent(sticker, mtx::events::EventType::Sticker); } -void +bool InputBar::command(const QString &command, QString args) { if (command == QLatin1String("me")) { @@ -829,16 +895,16 @@ InputBar::command(const QString &command, QString args) // 1 - Going directly to a given event ID if (args[0] == '$') { room->showEvent(args); - return; + return true; } // 2 - Going directly to a given message index if (args[0] >= '0' && args[0] <= '9') { room->showEvent(args); - return; + return true; } // 3 - Matrix URI handler, as if you clicked the URI if (ChatPage::instance()->handleMatrixUri(args)) { - return; + return true; } nhlog::net()->error("Could not resolve goto: {}", args.toStdString()); } else if (command == QLatin1String("converttodm")) { @@ -846,7 +912,11 @@ InputBar::command(const QString &command, QString args) cache::getMembers(this->room->roomId().toStdString(), 0, -1)); } else if (command == QLatin1String("converttoroom")) { utils::removeDirectFromRoom(this->room->roomId()); + } else { + return false; } + + return true; } MediaUpload::MediaUpload(std::unique_ptr source_, diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index 80ad7f47..acafd964 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -173,6 +173,11 @@ 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(QString text READ text NOTIFY textChanged) Q_PROPERTY(QVariantList uploads READ uploads NOTIFY uploadsChanged) @@ -198,6 +203,9 @@ public slots: void setText(const QString &newText); [[nodiscard]] bool containsAtRoom() const { return containsAtRoom_; } + bool containsInvalidCommand() const { return containsInvalidCommand_; } + bool containsIncompleteCommand() const { return containsIncompleteCommand_; } + QString currentCommand() const { return currentCommand_; } void send(); bool tryPasteAttachment(bool fromMouse); @@ -225,13 +233,16 @@ signals: void textChanged(QString newText); void uploadingChanged(bool value); void containsAtRoomChanged(); + void containsInvalidCommandChanged(); + void containsIncompleteCommandChanged(); + void currentCommandChanged(); void uploadsChanged(); private: void emote(const QString &body, bool rainbowify); void notice(const QString &body, bool rainbowify); void confetti(const QString &body, bool rainbowify); - void command(const QString &name, QString args); + bool command(const QString &name, QString args); void image(const QString &filename, const std::optional &file, const QString &url, @@ -267,6 +278,8 @@ private: const QSize &thumbnailDimensions, const QString &blurhash); + QPair getCommandAndArgs() const { return getCommandAndArgs(text()); } + QPair getCommandAndArgs(const QString ¤tText) const; mtx::common::Relations generateRelations() const; void startUploadFromPath(const QString &path); @@ -280,7 +293,7 @@ private: } } - void updateAtRoom(const QString &t); + void updateTextContentProperties(const QString &t); QTimer typingRefresh_; QTimer typingTimeout_; @@ -288,8 +301,11 @@ private: std::deque history_; std::size_t history_index_ = 0; int selectionStart = 0, selectionEnd = 0, cursorPosition = 0; - bool uploading_ = false; - bool containsAtRoom_ = false; + bool uploading_ = false; + bool containsAtRoom_ = false; + bool containsInvalidCommand_ = false; + bool containsIncompleteCommand_ = false; + QString currentCommand_; using UploadHandle = std::unique_ptr; std::vector unconfirmedUploads;