diff --git a/CHANGELOG.md b/CHANGELOG.md index 93290a1f8..21ace840d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Minor: Improve appearance of reply button. (#5491) - Minor: Introduce HTTP API for plugins. (#5383, #5492, #5494) - Minor: Support more Firefox variants for incognito link opening. (#5503) +- Minor: Replying to a message will now display the message being replied to. (#4350) - Minor: Links can now have prefixes and suffixes such as parentheses. (#5486) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 849397b7d..9df047665 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -643,6 +643,8 @@ set(SOURCE_FILES widgets/helper/IconDelegate.hpp widgets/helper/InvisibleSizeGrip.cpp widgets/helper/InvisibleSizeGrip.hpp + widgets/helper/MessageView.cpp + widgets/helper/MessageView.hpp widgets/helper/NotebookButton.cpp widgets/helper/NotebookButton.hpp widgets/helper/NotebookTab.cpp diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 0fb47cd0e..c49e53802 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -566,7 +566,7 @@ SingleLineTextElement::SingleLineTextElement(const QString &text, void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) { - auto *app = getApp(); + auto *app = getIApp(); if (flags.hasAny(this->getFlags())) { diff --git a/src/widgets/helper/MessageView.cpp b/src/widgets/helper/MessageView.cpp new file mode 100644 index 000000000..6383ec5a6 --- /dev/null +++ b/src/widgets/helper/MessageView.cpp @@ -0,0 +1,134 @@ +#include "widgets/helper/MessageView.hpp" + +#include "Application.hpp" +#include "messages/layouts/MessageLayout.hpp" +#include "messages/MessageElement.hpp" +#include "messages/Selection.hpp" +#include "providers/colors/ColorProvider.hpp" +#include "singletons/Theme.hpp" +#include "singletons/WindowManager.hpp" + +#include +#include + +namespace { + +using namespace chatterino; + +const Selection EMPTY_SELECTION; + +const MessageElementFlags MESSAGE_FLAGS{ + MessageElementFlag::Text, + MessageElementFlag::EmojiAll, + MessageElementFlag::EmoteText, +}; + +} // namespace + +namespace chatterino { + +MessageView::MessageView() = default; +MessageView::~MessageView() = default; + +void MessageView::createMessageLayout() +{ + if (this->message_ == nullptr) + { + this->messageLayout_.reset(); + return; + } + + this->messageLayout_ = std::make_unique(this->message_); +} + +void MessageView::setMessage(const MessagePtr &message) +{ + if (!message) + { + return; + } + + auto singleLineMessage = std::make_shared(); + singleLineMessage->elements.emplace_back( + std::make_unique( + message->messageText, MESSAGE_FLAGS, MessageColor::Type::System, + FontStyle::ChatMediumSmall)); + this->message_ = std::move(singleLineMessage); + this->createMessageLayout(); + this->layoutMessage(); +} + +void MessageView::clearMessage() +{ + this->setMessage(nullptr); +} + +void MessageView::setWidth(int width) +{ + if (this->width_ != width) + { + this->width_ = width; + this->layoutMessage(); + } +} + +void MessageView::paintEvent(QPaintEvent * /*event*/) +{ + QPainter painter(this); + + auto ctx = MessagePaintContext{ + .painter = painter, + .selection = EMPTY_SELECTION, + .colorProvider = ColorProvider::instance(), + .messageColors = this->messageColors_, + .preferences = this->messagePreferences_, + + .canvasWidth = this->width_, + .isWindowFocused = this->window() == QApplication::activeWindow(), + .isMentions = false, + + .y = 0, + .messageIndex = 0, + .isLastReadMessage = false, + }; + + this->messageLayout_->paint(ctx); +} + +void MessageView::themeChangedEvent() +{ + this->messageColors_.applyTheme(getTheme()); + this->messageColors_.regular = getTheme()->splits.input.background; + if (this->messageLayout_) + { + this->messageLayout_->invalidateBuffer(); + } +} + +void MessageView::scaleChangedEvent(float newScale) +{ + (void)newScale; + + this->layoutMessage(); +} + +void MessageView::layoutMessage() +{ + if (this->messageLayout_ == nullptr) + { + return; + } + + bool updateRequired = this->messageLayout_->layout( + this->width_, this->scale(), + this->scale() * static_cast(this->devicePixelRatio()), + MESSAGE_FLAGS, false); + + if (updateRequired) + { + this->setFixedSize(this->width_, this->messageLayout_->getHeight()); + this->update(); + } +} + +} // namespace chatterino diff --git a/src/widgets/helper/MessageView.hpp b/src/widgets/helper/MessageView.hpp new file mode 100644 index 000000000..242b1b496 --- /dev/null +++ b/src/widgets/helper/MessageView.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "messages/layouts/MessageLayoutContext.hpp" +#include "messages/Message.hpp" +#include "widgets/BaseWidget.hpp" + +#include + +namespace chatterino { + +class MessageLayout; + +/// MessageView is a fixed-width widget that displays a single message. +/// For the message to be rendered, you must call setWidth. +class MessageView : public BaseWidget +{ + Q_OBJECT + +public: + MessageView(); + ~MessageView() override; + MessageView(const MessageView &) = delete; + MessageView(MessageView &&) = delete; + MessageView &operator=(const MessageView &) = delete; + MessageView &operator=(MessageView &&) = delete; + + void setMessage(const MessagePtr &message); + void clearMessage(); + + void setWidth(int width); + +protected: + void paintEvent(QPaintEvent *event) override; + void themeChangedEvent() override; + void scaleChangedEvent(float newScale) override; + +private: + void createMessageLayout(); + void layoutMessage(); + + MessagePtr message_; + std::unique_ptr messageLayout_; + + MessageColors messageColors_; + MessagePreferences messagePreferences_; + + int width_{}; +}; + +} // namespace chatterino diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 865392163..66940b280 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -7,7 +7,6 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "messages/Link.hpp" #include "messages/Message.hpp" -#include "messages/MessageThread.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -19,6 +18,7 @@ #include "widgets/dialogs/EmotePopup.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" +#include "widgets/helper/MessageView.hpp" #include "widgets/helper/ResizingTextEdit.hpp" #include "widgets/Notebook.hpp" #include "widgets/Scrollbar.hpp" @@ -84,14 +84,28 @@ void SplitInput::initLayout() auto layout = layoutCreator.setLayoutType().withoutMargin().assign( &this->ui_.vbox); + layout->setSpacing(0); + auto marginPx = this->marginForTheme(); + layout->setContentsMargins(marginPx, marginPx, marginPx, marginPx); // reply label stuff auto replyWrapper = layout.emplace().assign(&this->ui_.replyWrapper); - this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0); + replyWrapper->setContentsMargins(0, 0, 0, 0); - auto replyHbox = replyWrapper.emplace().withoutMargin().assign( - &this->ui_.replyHbox); + auto replyVbox = + replyWrapper.setLayoutType().withoutMargin().assign( + &this->ui_.replyVbox); + replyVbox->setSpacing(0); + + auto replyHbox = + replyVbox.emplace().assign(&this->ui_.replyHbox); + + auto messageVbox = layoutCreator.setLayoutType(); + this->ui_.replyMessage = new MessageView(); + messageVbox->addWidget(this->ui_.replyMessage, 1, Qt::AlignLeft); + messageVbox->setContentsMargins(10, 0, 0, 0); + replyVbox->addLayout(messageVbox->layout(), 1); auto replyLabel = replyHbox.emplace().assign(&this->ui_.replyLabel); replyLabel->setAlignment(Qt::AlignLeft); @@ -107,9 +121,14 @@ void SplitInput::initLayout() replyCancelButton->hide(); replyLabel->hide(); + auto inputWrapper = + layout.emplace().assign(&this->ui_.inputWrapper); + inputWrapper->setContentsMargins(0, 0, 0, 0); + // hbox for input, right box auto hboxLayout = - layout.emplace().withoutMargin().assign(&this->ui_.hbox); + inputWrapper.setLayoutType().withoutMargin().assign( + &this->ui_.inputHbox); // input auto textEdit = @@ -176,7 +195,7 @@ void SplitInput::initLayout() this->openEmotePopup(); }); - // clear input and remove reply thread + // clear input and remove reply target QObject::connect(this->ui_.cancelReplyButton, &EffectLabel::leftClicked, [this] { this->clearInput(); @@ -211,6 +230,10 @@ void SplitInput::scaleChangedEvent(float scale) if (!this->hidden) { this->setMaximumHeight(this->scaledMaxHeight()); + if (this->replyTarget_ != nullptr) + { + this->ui_.vbox->setSpacing(this->marginForTheme() * 2); + } } this->ui_.textEdit->setFont( app->getFonts()->getFont(FontStyle::ChatMedium, scale)); @@ -236,8 +259,6 @@ void SplitInput::themeChangedEvent() this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet); this->ui_.textEdit->setPalette(placeholderPalette); - auto marginPx = static_cast(2.F * this->scale()); - this->ui_.vbox->setContentsMargins(marginPx, marginPx, marginPx, marginPx); this->ui_.emoteButton->getLabel().setStyleSheet("color: #000"); @@ -249,6 +270,14 @@ void SplitInput::themeChangedEvent() { this->ui_.replyLabel->setStyleSheet("color: #ccc"); } + + // update vbox + auto marginPx = this->marginForTheme(); + this->ui_.vbox->setContentsMargins(marginPx, marginPx, marginPx, marginPx); + if (this->replyTarget_ != nullptr) + { + this->ui_.vbox->setSpacing(this->marginForTheme() * 2); + } } void SplitInput::updateEmoteButton() @@ -319,7 +348,7 @@ QString SplitInput::handleSendMessage(const std::vector &arguments) return ""; } - if (!c->isTwitchChannel() || this->replyThread_ == nullptr) + if (!c->isTwitchChannel() || this->replyTarget_ == nullptr) { // standard message send behavior QString message = ui_.textEdit->toPlainText(); @@ -338,7 +367,36 @@ QString SplitInput::handleSendMessage(const std::vector &arguments) auto *tc = dynamic_cast(c.get()); if (!tc) { - // this should not fail + // Reply to message + auto tc = dynamic_cast(c.get()); + if (!tc) + { + // this should not fail + return ""; + } + + QString message = this->ui_.textEdit->toPlainText(); + + if (this->enableInlineReplying_) + { + // Remove @username prefix that is inserted when doing inline replies + message.remove(0, this->replyTarget_->displayName.length() + + 1); // remove "@username" + + if (!message.isEmpty() && message.at(0) == ' ') + { + message.remove(0, 1); // remove possible space + } + } + + message = message.replace('\n', ' '); + QString sendMessage = + getIApp()->getCommands()->execCommand(message, c, false); + + // Reply within TwitchChannel + tc->sendReply(sendMessage, this->replyTarget_->id); + + this->postMessageSend(message, arguments); return ""; } @@ -347,7 +405,7 @@ QString SplitInput::handleSendMessage(const std::vector &arguments) if (this->enableInlineReplying_) { // Remove @username prefix that is inserted when doing inline replies - message.remove(0, this->replyThread_->displayName.length() + + message.remove(0, this->replyTarget_->displayName.length() + 1); // remove "@username" if (!message.isEmpty() && message.at(0) == ' ') @@ -361,7 +419,7 @@ QString SplitInput::handleSendMessage(const std::vector &arguments) getIApp()->getCommands()->execCommand(message, c, false); // Reply within TwitchChannel - tc->sendReply(sendMessage, this->replyThread_->id); + tc->sendReply(sendMessage, this->replyTarget_->id); this->postMessageSend(message, arguments); return ""; @@ -386,7 +444,15 @@ void SplitInput::postMessageSend(const QString &message, int SplitInput::scaledMaxHeight() const { - return int(150 * this->scale()); + if (this->replyTarget_ != nullptr) + { + // give more space for showing the message being replied to + return int(250 * this->scale()); + } + else + { + return int(150 * this->scale()); + } } void SplitInput::addShortcuts() @@ -1001,24 +1067,24 @@ void SplitInput::editTextChanged() bool hasReply = false; if (this->enableInlineReplying_) { - if (this->replyThread_ != nullptr) + if (this->replyTarget_ != nullptr) { // Check if the input still starts with @username. If not, don't reply. // // We need to verify that // 1. the @username prefix exists and // 2. if a character exists after the @username, it is a space - QString replyPrefix = "@" + this->replyThread_->displayName; + QString replyPrefix = "@" + this->replyTarget_->displayName; if (!text.startsWith(replyPrefix) || (text.length() > replyPrefix.length() && text.at(replyPrefix.length()) != ' ')) { - this->replyThread_ = nullptr; + this->clearReplyTarget(); } } // Show/hide reply label if inline replies are possible - hasReply = this->replyThread_ != nullptr; + hasReply = this->replyTarget_ != nullptr; } this->ui_.replyWrapper->setVisible(hasReply); @@ -1030,37 +1096,35 @@ void SplitInput::paintEvent(QPaintEvent * /*event*/) { QPainter painter(this); - int s{}; - QColor borderColor; + int s = this->marginForTheme(); + QColor borderColor = + this->theme->isLightTheme() ? QColor("#ccc") : QColor("#333"); - if (this->theme->isLightTheme()) - { - s = int(3 * this->scale()); - borderColor = QColor("#ccc"); - } - else - { - s = int(1 * this->scale()); - borderColor = QColor("#333"); - } - - QMargins removeMargins(s - 1, s - 1, s, s); QRect baseRect = this->rect(); + QRect inputBoxRect = this->ui_.inputWrapper->geometry(); + inputBoxRect.setX(baseRect.x()); + inputBoxRect.setWidth(baseRect.width()); - // completeAreaRect includes the reply label - QRect completeAreaRect = baseRect.marginsRemoved(removeMargins); - painter.fillRect(completeAreaRect, this->theme->splits.input.background); + painter.fillRect(inputBoxRect, this->theme->splits.input.background); painter.setPen(borderColor); - painter.drawRect(completeAreaRect); + painter.drawRect(inputBoxRect); - if (this->enableInlineReplying_ && this->replyThread_ != nullptr) + if (this->enableInlineReplying_ && this->replyTarget_ != nullptr) { - // Move top of rect down to not include reply label - baseRect.setTop(baseRect.top() + this->ui_.replyWrapper->height()); + QRect replyRect = this->ui_.replyWrapper->geometry(); + replyRect.setX(baseRect.x()); + replyRect.setWidth(baseRect.width()); - QRect onlyInputRect = baseRect.marginsRemoved(removeMargins); + painter.fillRect(replyRect, this->theme->splits.input.background); painter.setPen(borderColor); - painter.drawRect(onlyInputRect); + painter.drawRect(replyRect); + + QPoint replyLabelBorderStart( + replyRect.x(), + replyRect.y() + this->ui_.replyHbox->geometry().height()); + QPoint replyLabelBorderEnd(replyRect.right(), + replyLabelBorderStart.y()); + painter.drawLine(replyLabelBorderStart, replyLabelBorderEnd); } } @@ -1076,6 +1140,8 @@ void SplitInput::resizeEvent(QResizeEvent *event) { this->ui_.textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); } + + this->ui_.replyMessage->setWidth(this->width()); } void SplitInput::giveFocus(Qt::FocusReason reason) @@ -1083,9 +1149,9 @@ void SplitInput::giveFocus(Qt::FocusReason reason) this->ui_.textEdit->setFocus(reason); } -void SplitInput::setReply(MessagePtr reply, bool showReplyingLabel) +void SplitInput::setReply(MessagePtr target) { - auto oldParent = this->replyThread_; + auto oldParent = this->replyTarget_; if (this->enableInlineReplying_ && oldParent) { // Remove old reply prefix @@ -1100,12 +1166,24 @@ void SplitInput::setReply(MessagePtr reply, bool showReplyingLabel) this->ui_.textEdit->resetCompletion(); } - this->replyThread_ = std::move(reply); + assert(target != nullptr); + this->replyTarget_ = std::move(target); if (this->enableInlineReplying_) { + this->ui_.replyMessage->setMessage(this->replyTarget_); + this->ui_.replyMessage->setWidth(this->width()); + + // add spacing between reply box and input box + this->ui_.vbox->setSpacing(this->marginForTheme() * 2); + if (!this->isHidden()) + { + // update maximum height to give space for message + this->setMaximumHeight(this->scaledMaxHeight()); + } + // Only enable reply label if inline replying - auto replyPrefix = "@" + this->replyThread_->displayName; + auto replyPrefix = "@" + this->replyTarget_->displayName; auto plainText = this->ui_.textEdit->toPlainText().trimmed(); // This makes it so if plainText contains "@StreamerFan" and @@ -1134,7 +1212,7 @@ void SplitInput::setReply(MessagePtr reply, bool showReplyingLabel) this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); this->ui_.textEdit->resetCompletion(); this->ui_.replyLabel->setText("Replying to @" + - this->replyThread_->displayName); + this->replyTarget_->displayName); } } @@ -1148,9 +1226,17 @@ void SplitInput::clearInput() this->currMsg_ = ""; this->ui_.textEdit->setText(""); this->ui_.textEdit->moveCursor(QTextCursor::Start); - if (this->enableInlineReplying_) + this->clearReplyTarget(); +} + +void SplitInput::clearReplyTarget() +{ + this->replyTarget_.reset(); + this->ui_.replyMessage->clearMessage(); + this->ui_.vbox->setSpacing(0); + if (!this->isHidden()) { - this->replyThread_ = nullptr; + this->setMaximumHeight(this->scaledMaxHeight()); } } @@ -1177,4 +1263,16 @@ bool SplitInput::shouldPreventInput(const QString &text) const return text.length() > TWITCH_MESSAGE_LIMIT; } +int SplitInput::marginForTheme() const +{ + if (this->theme->isLightTheme()) + { + return int(3 * this->scale()); + } + else + { + return int(1 * this->scale()); + } +} + } // namespace chatterino diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index f66d5d27f..8319f9f71 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -20,6 +20,7 @@ class Split; class EmotePopup; class InputCompletionPopup; class EffectLabel; +class MessageView; class ResizingTextEdit; class ChannelView; enum class CompletionKind; @@ -40,7 +41,7 @@ public: QString getInputText() const; void insertText(const QString &text); - void setReply(MessagePtr reply, bool showInlineReplying = true); + void setReply(MessagePtr target); void setPlaceholderText(const QString &text); /** @@ -91,7 +92,7 @@ protected: void postMessageSend(const QString &message, const std::vector &arguments); - /// Clears the input box, clears reply thread if inline replies are enabled + /// Clears the input box, clears reply target if inline replies are enabled void clearInput(); void addShortcuts() override; @@ -109,6 +110,7 @@ protected: void hideCompletionPopup(); void insertCompletionText(const QString &input_) const; void openEmotePopup(); + void clearReplyTarget(); void updateCancelReplyButton(); @@ -120,27 +122,35 @@ protected: // the user's setting is set to Prevent, and the given text goes beyond the Twitch message length limit bool shouldPreventInput(const QString &text) const; + int marginForTheme() const; + Split *const split_; ChannelView *const channelView_; QPointer emotePopup_; QPointer inputCompletionPopup_; struct { + // vbox for all components + QVBoxLayout *vbox; + + // reply widgets + QWidget *replyWrapper; + QVBoxLayout *replyVbox; + QHBoxLayout *replyHbox; + MessageView *replyMessage; + QLabel *replyLabel; + EffectLabel *cancelReplyButton; + + // input widgets + QWidget *inputWrapper; + QHBoxLayout *inputHbox; ResizingTextEdit *textEdit; QLabel *textEditLength; EffectLabel *sendButton; EffectLabel *emoteButton; + } ui_; - QHBoxLayout *hbox; - QVBoxLayout *vbox; - - QWidget *replyWrapper; - QHBoxLayout *replyHbox; - QLabel *replyLabel; - EffectLabel *cancelReplyButton; - } ui_{}; - - MessagePtr replyThread_ = nullptr; + MessagePtr replyTarget_ = nullptr; bool enableInlineReplying_; pajlada::Signals::SignalHolder managedConnections_;