From 20c974fdab215c54297cc5a47ea7d1e03c6065be Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sun, 31 Jul 2022 06:45:25 -0400 Subject: [PATCH] Added support for Twitch's Chat Replies (#3722) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + resources/buttons/cancel.svg | 1 + resources/buttons/cancelDark.svg | 1 + resources/buttons/replyDark.png | Bin 0 -> 769 bytes resources/buttons/replyDark.svg | 1 + resources/buttons/replyThreadDark.png | Bin 0 -> 671 bytes resources/buttons/replyThreadDark.svg | 1 + resources/resources_autogenerated.qrc | 6 + src/CMakeLists.txt | 6 + src/autogenerated/ResourcesAutogen.cpp | 2 + src/autogenerated/ResourcesAutogen.hpp | 2 + src/common/Channel.cpp | 7 + src/common/Channel.hpp | 4 + src/common/Common.hpp | 1 + .../commands/CommandController.cpp | 54 ++- .../filters/parser/FilterParser.cpp | 1 + src/controllers/filters/parser/Tokenizer.hpp | 1 + src/messages/Link.hpp | 2 + src/messages/Message.hpp | 6 + src/messages/MessageElement.cpp | 178 ++++++++++ src/messages/MessageElement.hpp | 59 ++++ src/messages/MessageThread.cpp | 61 ++++ src/messages/MessageThread.hpp | 47 +++ src/messages/layouts/MessageLayout.cpp | 33 ++ src/messages/layouts/MessageLayout.hpp | 4 + .../layouts/MessageLayoutContainer.cpp | 8 + src/messages/layouts/MessageLayoutElement.cpp | 102 ++++++ src/messages/layouts/MessageLayoutElement.hpp | 38 +++ src/providers/twitch/IrcMessageHandler.cpp | 110 ++++++- src/providers/twitch/IrcMessageHandler.hpp | 12 + src/providers/twitch/TwitchChannel.cpp | 188 ++++++++--- src/providers/twitch/TwitchChannel.hpp | 25 +- src/providers/twitch/TwitchIrcServer.cpp | 125 ++++--- src/providers/twitch/TwitchIrcServer.hpp | 4 + src/providers/twitch/TwitchMessageBuilder.cpp | 194 ++++++++--- src/providers/twitch/TwitchMessageBuilder.hpp | 4 + src/singletons/Settings.hpp | 3 + src/singletons/WindowManager.cpp | 5 + src/widgets/DraggablePopup.cpp | 90 +++++ src/widgets/DraggablePopup.hpp | 47 +++ src/widgets/dialogs/ReplyThreadPopup.cpp | 193 +++++++++++ src/widgets/dialogs/ReplyThreadPopup.hpp | 48 +++ src/widgets/dialogs/UserInfoPopup.cpp | 89 +---- src/widgets/dialogs/UserInfoPopup.hpp | 27 +- src/widgets/helper/ChannelView.cpp | 151 ++++++++- src/widgets/helper/ChannelView.hpp | 20 +- src/widgets/helper/SearchPopup.cpp | 6 +- src/widgets/helper/SearchPopup.hpp | 4 +- src/widgets/settingspages/GeneralPage.cpp | 4 + src/widgets/splits/Split.cpp | 10 +- src/widgets/splits/Split.hpp | 3 + src/widgets/splits/SplitInput.cpp | 310 ++++++++++++++---- src/widgets/splits/SplitInput.hpp | 33 +- 53 files changed, 2022 insertions(+), 310 deletions(-) create mode 100644 resources/buttons/cancel.svg create mode 100644 resources/buttons/cancelDark.svg create mode 100644 resources/buttons/replyDark.png create mode 100644 resources/buttons/replyDark.svg create mode 100644 resources/buttons/replyThreadDark.png create mode 100644 resources/buttons/replyThreadDark.svg create mode 100644 src/messages/MessageThread.cpp create mode 100644 src/messages/MessageThread.hpp create mode 100644 src/widgets/DraggablePopup.cpp create mode 100644 src/widgets/DraggablePopup.hpp create mode 100644 src/widgets/dialogs/ReplyThreadPopup.cpp create mode 100644 src/widgets/dialogs/ReplyThreadPopup.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e01bf44..1c8710595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Minor: Added option to display tabs on the right and bottom. (#3847) - Minor: Added `is:first-msg` search option. (#3700) diff --git a/resources/buttons/cancel.svg b/resources/buttons/cancel.svg new file mode 100644 index 000000000..7bc012dae --- /dev/null +++ b/resources/buttons/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/buttons/cancelDark.svg b/resources/buttons/cancelDark.svg new file mode 100644 index 000000000..5c231ffdb --- /dev/null +++ b/resources/buttons/cancelDark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/buttons/replyDark.png b/resources/buttons/replyDark.png new file mode 100644 index 0000000000000000000000000000000000000000..0b47b7b920aaeb9ef2ba8932ca956a01a1ccfd23 GIT binary patch literal 769 zcmV+c1OEJpP)uoL_&f2e^kK$!saU%z)AC!3LwgSWdevZ zUx@&c%zGa|BlBJdkjA{X0i-qWWdK>sdlx`f^Iipz$GkTIu=&rF-F(0|@E(kVL2%t| z`+CwfAIBFk3HrfhXKpeo@7QPM4_E*LRG<$iwpP9oKfw#oVc%lBT&;W(zri%n?vEoB*q&PIa9C`0LV*9DPJ8YX~Op1=_$6cn9_} z)DAOeppPSE3BmNm$QAGutf#CWz*A&w3Rr1Fuyr%UkGuE4Vu~IDW=uDcCIlB(V|Pg% zpp6392iHs&@1f)nT-^=wL_7vR5_)0AY!pv=PH=rWdWqDPTU-Df&{-RW<0e7scK8w4 zjp&EzwN*4~5+tw3yD0kC!uVkr*2xg0{~ypw>MkK1zz(=zvuJobNC&`Tv;1Zl4x!I> z(O?pa5CDr0zBhCnz@&?!JPGSA3Z)d^AABSgz*kpAyId7YDSj(mBNf6;;a \ No newline at end of file diff --git a/resources/buttons/replyThreadDark.png b/resources/buttons/replyThreadDark.png new file mode 100644 index 0000000000000000000000000000000000000000..3d6c0b437667ea5fc5b110cb28d20f60e568b86a GIT binary patch literal 671 zcmV;Q0$}}#P)9K z-@HixuHUN=qMJ_}fa~`vg!twa>r8*p13;+V{0`F}^Z*d*A3oRtAk=Oenf^*Xh-=V; z*d(Tic6U+Cg^*qNI9`b(Vwvc66DysPnn$MJh7#!AF~N_=YaW??7n~8Z+@LniyE^b( zaY798BByU2nf`k5Mr;!;_R;ejzLHa-w;tg`+xE41AckEJ0CO||MS++$9l}OJ(S4>r zYl_6A=@2lXC^G$7^FoZ84x!O@!XsE5jF?aqnf|OfC)!Mh&}chh*K`0%m{1g%{w#SX zMnWIL6X#k8SKLm<;%YXDJF zzvK|H3i=ZGD@A>Ux>D>gSjQ-Y&xfJ|#2#^ \ No newline at end of file diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index 3516addd4..2c91e0282 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -20,6 +20,8 @@ buttons/addSplitDark.png buttons/ban.png buttons/banRed.png + buttons/cancel.svg + buttons/cancelDark.svg buttons/clearSearch.png buttons/copyDark.png buttons/copyDark.svg @@ -34,6 +36,10 @@ buttons/modModeDisabled2.png buttons/modModeEnabled.png buttons/modModeEnabled2.png + buttons/replyDark.png + buttons/replyDark.svg + buttons/replyThreadDark.png + buttons/replyThreadDark.svg buttons/search.png buttons/timeout.png buttons/trashCan.png diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5b76d4b57..a943a31a2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -144,6 +144,8 @@ set(SOURCE_FILES messages/MessageContainer.hpp messages/MessageElement.cpp messages/MessageElement.hpp + messages/MessageThread.cpp + messages/MessageThread.hpp messages/SharedMessageBuilder.cpp messages/SharedMessageBuilder.hpp @@ -347,6 +349,8 @@ set(SOURCE_FILES widgets/BaseWidget.hpp widgets/BaseWindow.cpp widgets/BaseWindow.hpp + widgets/DraggablePopup.cpp + widgets/DraggablePopup.hpp widgets/FramelessEmbedWindow.cpp widgets/FramelessEmbedWindow.hpp widgets/Label.cpp @@ -383,6 +387,8 @@ set(SOURCE_FILES widgets/dialogs/NotificationPopup.hpp widgets/dialogs/QualityPopup.cpp widgets/dialogs/QualityPopup.hpp + widgets/dialogs/ReplyThreadPopup.cpp + widgets/dialogs/ReplyThreadPopup.hpp widgets/dialogs/SelectChannelDialog.cpp widgets/dialogs/SelectChannelDialog.hpp widgets/dialogs/SelectChannelFiltersDialog.cpp diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index 7f9bba594..9661241da 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -32,6 +32,8 @@ Resources2::Resources2() this->buttons.modModeDisabled2 = QPixmap(":/buttons/modModeDisabled2.png"); this->buttons.modModeEnabled = QPixmap(":/buttons/modModeEnabled.png"); this->buttons.modModeEnabled2 = QPixmap(":/buttons/modModeEnabled2.png"); + this->buttons.replyDark = QPixmap(":/buttons/replyDark.png"); + this->buttons.replyThreadDark = QPixmap(":/buttons/replyThreadDark.png"); this->buttons.search = QPixmap(":/buttons/search.png"); this->buttons.timeout = QPixmap(":/buttons/timeout.png"); this->buttons.trashCan = QPixmap(":/buttons/trashCan.png"); diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index 0e40fe3ea..7aed77a2b 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -39,6 +39,8 @@ public: QPixmap modModeDisabled2; QPixmap modModeEnabled; QPixmap modModeEnabled2; + QPixmap replyDark; + QPixmap replyThreadDark; QPixmap search; QPixmap timeout; QPixmap trashCan; diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 374035e2a..9b700f0c5 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -278,6 +278,13 @@ bool Channel::canSendMessage() const return false; } +bool Channel::isWritable() const +{ + using Type = Channel::Type; + auto type = this->getType(); + return type != Type::TwitchMentions && type != Type::TwitchLive; +} + void Channel::sendMessage(const QString &message) { } diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 0b7c81653..7b9f4fd6b 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -49,6 +49,9 @@ public: // SIGNALS pajlada::Signals::Signal sendMessageSignal; + pajlada::Signals::Signal + sendReplySignal; pajlada::Signals::Signal messageRemovedFromStart; pajlada::Signals::Signal> messageAppended; @@ -84,6 +87,7 @@ public: // CHANNEL INFO virtual bool canSendMessage() const; + virtual bool isWritable() const; // whether split input will be usable virtual void sendMessage(const QString &message); virtual bool isMod() const; virtual bool isBroadcaster() const; diff --git a/src/common/Common.hpp b/src/common/Common.hpp index 1dded1d26..2284fd0ea 100644 --- a/src/common/Common.hpp +++ b/src/common/Common.hpp @@ -51,6 +51,7 @@ using MessagePtr = std::shared_ptr; enum class CopyMode { Everything, + EverythingButReplies, OnlyTextAndEmotes, }; diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index b0db5e731..702262510 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -26,6 +26,7 @@ #include "util/StreamLink.hpp" #include "util/Twitch.hpp" #include "widgets/Window.hpp" +#include "widgets/dialogs/ReplyThreadPopup.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/splits/Split.hpp" @@ -687,7 +688,8 @@ void CommandController::initialize(Settings &, Paths &paths) auto *userPopup = new UserInfoPopup( getSettings()->autoCloseUserPopup, - static_cast(&(getApp()->windows->getMainWindow()))); + static_cast(&(getApp()->windows->getMainWindow())), + nullptr); userPopup->setData(userName, channel); userPopup->move(QCursor::pos()); userPopup->show(); @@ -1114,6 +1116,56 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + this->registerCommand( + "/reply", [](const QStringList &words, ChannelPtr channel) { + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /reply command only works in Twitch channels")); + return ""; + } + + if (words.size() < 3) + { + channel->addMessage( + makeSystemMessage("Usage: /reply ")); + return ""; + } + + QString username = words[1]; + stripChannelName(username); + + auto snapshot = twitchChannel->getMessageSnapshot(); + for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) + { + const auto &msg = *it; + if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) + { + std::shared_ptr thread; + // found most recent message by user + if (msg->replyThread == nullptr) + { + thread = std::make_shared(msg); + twitchChannel->addReplyThread(thread); + } + else + { + thread = msg->replyThread; + } + + QString reply = words.mid(2).join(" "); + twitchChannel->sendReply(reply, thread->rootId()); + return ""; + } + } + + channel->addMessage( + makeSystemMessage("A message from that user wasn't found")); + + return ""; + }); + #ifndef NDEBUG this->registerCommand( "/fakemsg", diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index 3e973da77..f4c80efa6 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -82,6 +82,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) m->flags.has(MessageFlag::RedeemedChannelPointReward)}, {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, + {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, {"message.content", m->messageText}, {"message.length", m->messageText.length()}, diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index f37026539..752616078 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -25,6 +25,7 @@ static const QMap validIdentifiersMap = { {"flags.reward_message", "channel point reward message?"}, {"flags.first_message", "first message?"}, {"flags.whisper", "whisper message?"}, + {"flags.reply", "reply message?"}, {"message.content", "message text"}, {"message.length", "message length"}}; diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index 616ecf87d..f6a48a7d3 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -23,6 +23,8 @@ public: JumpToChannel, Reconnect, CopyToClipboard, + ReplyToMessage, + ViewThread, }; Link(); diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index a5969daaa..cc9f36503 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -13,6 +13,7 @@ namespace chatterino { class MessageElement; +class MessageThread; enum class MessageFlag : uint32_t { None = 0, @@ -40,6 +41,7 @@ enum class MessageFlag : uint32_t { RedeemedChannelPointReward = (1 << 21), ShowInMentions = (1 << 22), FirstMessage = (1 << 23), + ReplyMessage = (1 << 24), }; using MessageFlags = FlagsEnum; @@ -68,6 +70,10 @@ struct Message : boost::noncopyable { std::vector badges; std::unordered_map badgeInfos; std::shared_ptr highlightColor; + // Each reply holds a reference to the thread. When every reply is dropped, + // the reply thread will be cleaned up by the TwitchChannel. + // The root of the thread does not have replyThread set. + std::shared_ptr replyThread; uint32_t count = 1; std::vector> elements; diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 3d3f4cde6..a266e5855 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -5,6 +5,8 @@ #include "messages/Emote.hpp" #include "messages/layouts/MessageLayoutContainer.hpp" #include "messages/layouts/MessageLayoutElement.hpp" +#include "providers/emoji/Emojis.hpp" +#include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "util/DebugCount.hpp" @@ -132,6 +134,31 @@ void ImageElement::addToContainer(MessageLayoutContainer &container, } } +CircularImageElement::CircularImageElement(ImagePtr image, int padding, + QColor background, + MessageElementFlags flags) + : MessageElement(flags) + , image_(image) + , padding_(padding) + , background_(background) +{ +} + +void CircularImageElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + auto imgSize = QSize(this->image_->width(), this->image_->height()) * + container.getScale(); + + container.addElement((new ImageWithCircleBackgroundLayoutElement( + *this, this->image_, imgSize, + this->background_, this->padding_)) + ->setLink(this->getLink())); + } +} + // EMOTE EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags, const MessageColor &textElementColor) @@ -395,6 +422,137 @@ void TextElement::addToContainer(MessageLayoutContainer &container, } } +SingleLineTextElement::SingleLineTextElement(const QString &text, + MessageElementFlags flags, + const MessageColor &color, + FontStyle style) + : MessageElement(flags) + , color_(color) + , style_(style) +{ + for (const auto &word : text.split(' ')) + { + this->words_.push_back({word, -1}); + } +} + +void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + auto app = getApp(); + + if (flags.hasAny(this->getFlags())) + { + QFontMetrics metrics = + app->fonts->getFontMetrics(this->style_, container.getScale()); + + auto getTextLayoutElement = [&](QString text, int width, + bool hasTrailingSpace) { + auto color = this->color_.getColor(*app->themes); + app->themes->normalizeColor(color); + + auto e = (new TextLayoutElement( + *this, text, QSize(width, metrics.height()), color, + this->style_, container.getScale())) + ->setLink(this->getLink()); + e->setTrailingSpace(hasTrailingSpace); + e->setText(text); + + // If URL link was changed, + // Should update it in MessageLayoutElement too! + if (this->getLink().type == Link::Url) + { + static_cast(e)->listenToLinkChanges(); + } + return e; + }; + + static const auto ellipsis = QStringLiteral("..."); + auto addEllipsis = [&]() { + int ellipsisSize = metrics.horizontalAdvance(ellipsis); + container.addElementNoLineBreak( + getTextLayoutElement(ellipsis, ellipsisSize, false)); + }; + + for (Word &word : this->words_) + { + auto parsedWords = app->emotes->emojis.parse(word.text); + if (parsedWords.size() == 0) + { + continue; // sanity check + } + + auto &parsedWord = parsedWords[0]; + if (parsedWord.type() == typeid(EmotePtr)) + { + auto emote = boost::get(parsedWord); + auto image = + emote->images.getImageOrLoaded(container.getScale()); + if (!image->isEmpty()) + { + auto emoteScale = getSettings()->emoteScale.getValue(); + + auto size = QSize(image->width(), image->height()) * + (emoteScale * container.getScale()); + + if (!container.fitsInLine(size.width())) + { + addEllipsis(); + break; + } + + container.addElementNoLineBreak( + (new ImageLayoutElement(*this, image, size)) + ->setLink(this->getLink())); + } + } + else if (parsedWord.type() == typeid(QString)) + { + word.width = metrics.horizontalAdvance(word.text); + + // see if the text fits in the current line + if (container.fitsInLine(word.width)) + { + container.addElementNoLineBreak(getTextLayoutElement( + word.text, word.width, this->hasTrailingSpace())); + } + else + { + // word overflows, try minimum truncation + bool cutSuccess = false; + for (size_t cut = 1; cut < word.text.length(); ++cut) + { + // Cut off n characters and append the ellipsis. + // Try removing characters one by one until the word fits. + QString truncatedWord = + word.text.chopped(cut) + ellipsis; + int newSize = metrics.horizontalAdvance(truncatedWord); + if (container.fitsInLine(newSize)) + { + container.addElementNoLineBreak( + getTextLayoutElement(truncatedWord, newSize, + false)); + cutSuccess = true; + break; + } + } + + if (!cutSuccess) + { + // We weren't able to show any part of the current word, so + // just append the ellipsis. + addEllipsis(); + } + + break; + } + } + } + + container.breakLine(); + } +} + // TIMESTAMP TimestampElement::TimestampElement(QTime time) : MessageElement(MessageElementFlag::Timestamp) @@ -502,4 +660,24 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container, } } +ReplyCurveElement::ReplyCurveElement() + : MessageElement(MessageElementFlag::RepliedMessage) + // these values nicely align with a single badge + , neededMargin_(3) + , size_(18, 14) +{ +} + +void ReplyCurveElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + QSize boxSize = this->size_ * container.getScale(); + container.addElement(new ReplyCurveLayoutElement( + *this, boxSize, 1.5 * container.getScale(), + this->neededMargin_ * container.getScale())); + } +} + } // namespace chatterino diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index c26df48fc..4cc47873a 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -126,6 +126,12 @@ enum class MessageElementFlag : int64_t { // e.g. BTTV's SoSnowy during christmas season ZeroWidthEmote = (1LL << 31), + // for elements of the message reply + RepliedMessage = (1LL << 32), + + // for the reply button element + ReplyButton = (1LL << 33), + Default = Timestamp | Badges | Username | BitsStatic | FfzEmoteImage | BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text | AlwaysShow, @@ -209,6 +215,22 @@ private: ImagePtr image_; }; +// contains a image with a circular background color +class CircularImageElement : public MessageElement +{ +public: + CircularImageElement(ImagePtr image, int padding, QColor background, + MessageElementFlags flags); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + +private: + ImagePtr image_; + int padding_; + QColor background_; +}; + // contains a text, it will split it into words class TextElement : public MessageElement { @@ -232,6 +254,29 @@ private: std::vector words_; }; +// contains a text that will be truncated to one line +class SingleLineTextElement : public MessageElement +{ +public: + SingleLineTextElement(const QString &text, MessageElementFlags flags, + const MessageColor &color = MessageColor::Text, + FontStyle style = FontStyle::ChatMedium); + ~SingleLineTextElement() override = default; + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + +private: + MessageColor color_; + FontStyle style_; + + struct Word { + QString text; + int width = -1; + }; + std::vector words_; +}; + // contains emote data and will pick the emote based on : // a) are images for the emote type enabled // b) which size it wants @@ -355,4 +400,18 @@ public: private: ImageSet images_; }; + +class ReplyCurveElement : public MessageElement +{ +public: + ReplyCurveElement(); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + +private: + int neededMargin_; + QSize size_; +}; + } // namespace chatterino diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp new file mode 100644 index 000000000..dc798d993 --- /dev/null +++ b/src/messages/MessageThread.cpp @@ -0,0 +1,61 @@ +#include "MessageThread.hpp" + +#include "messages/Message.hpp" +#include "util/DebugCount.hpp" + +#include + +namespace chatterino { + +MessageThread::MessageThread(std::shared_ptr rootMessage) + : rootMessageId_(rootMessage->id) + , rootMessage_(std::move(rootMessage)) +{ + DebugCount::increase("message threads"); +} + +MessageThread::~MessageThread() +{ + DebugCount::decrease("message threads"); +} + +void MessageThread::addToThread(const std::shared_ptr &message) +{ + this->replies_.emplace_back(message); +} + +void MessageThread::addToThread(const std::weak_ptr &message) +{ + this->replies_.push_back(message); +} + +size_t MessageThread::liveCount() const +{ + size_t count = 0; + for (auto reply : this->replies_) + { + if (!reply.expired()) + { + ++count; + } + } + + return count; +} + +size_t MessageThread::liveCount( + const std::shared_ptr &exclude) const +{ + size_t count = 0; + for (auto reply : this->replies_) + { + if (!reply.expired() && reply.lock() != exclude) + { + ++count; + } + } + + return count; +} + +} // namespace chatterino diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp new file mode 100644 index 000000000..f2a8e57d5 --- /dev/null +++ b/src/messages/MessageThread.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include +#include + +namespace chatterino { +struct Message; + +class MessageThread +{ +public: + MessageThread(std::shared_ptr rootMessage); + ~MessageThread(); + + void addToThread(const std::shared_ptr &message); + void addToThread(const std::weak_ptr &message); + + /// Returns the number of live reply references + size_t liveCount() const; + + /// Returns the number of live reply references + size_t liveCount(const std::shared_ptr &exclude) const; + + const QString &rootId() const + { + return rootMessageId_; + } + + const std::shared_ptr &root() const + { + return rootMessage_; + } + + const std::vector> &replies() const + { + return replies_; + } + +private: + const QString rootMessageId_; + const std::shared_ptr rootMessage_; + std::vector> replies_; +}; + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 1e48f7d02..f0f01f933 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -55,6 +55,11 @@ const Message *MessageLayout::getMessage() return this->message_.get(); } +const MessagePtr &MessageLayout::getMessagePtr() const +{ + return this->message_; +} + // Height int MessageLayout::getHeight() const { @@ -147,6 +152,12 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) continue; } + if (!this->renderReplies_ && + element->getFlags().has(MessageElementFlag::RepliedMessage)) + { + continue; + } + element->addToContainer(*this->container_, flags); } @@ -414,4 +425,26 @@ void MessageLayout::addSelectionText(QString &str, int from, int to, this->container_->addSelectionText(str, from, to, copymode); } +bool MessageLayout::isReplyable() const +{ + if (this->message_->loginName.isEmpty()) + { + return false; + } + + if (this->message_->flags.hasAny( + {MessageFlag::System, MessageFlag::Subscription, + MessageFlag::Timeout, MessageFlag::Whisper})) + { + return false; + } + + return true; +} + +void MessageLayout::setRenderReplies(bool render) +{ + this->renderReplies_ = render; +} + } // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index d097934d4..33c1fad72 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -37,6 +37,7 @@ public: ~MessageLayout(); const Message *getMessage(); + const MessagePtr &getMessagePtr() const; int getHeight() const; @@ -62,6 +63,8 @@ public: // Misc bool isDisabled() const; + bool isReplyable() const; + void setRenderReplies(bool render); private: // variables @@ -69,6 +72,7 @@ private: std::shared_ptr container_; std::shared_ptr buffer_{}; bool bufferValid_ = false; + bool renderReplies_ = true; int height_ = 0; diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 5fca4008a..eefc88ae0 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -661,6 +661,14 @@ void MessageLayoutContainer::addSelectionText(QString &str, int from, int to, for (auto &element : this->elements_) { + if (copymode != CopyMode::Everything && + element->getCreator().getFlags().has( + MessageElementFlag::RepliedMessage)) + { + // Don't include the message being replied to + continue; + } + if (copymode == CopyMode::OnlyTextAndEmotes) { if (element->getCreator().getFlags().hasAny( diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index fba472876..492a4c8bb 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace chatterino { @@ -205,6 +206,44 @@ void ImageWithBackgroundLayoutElement::paint(QPainter &painter) } } +// +// IMAGE WITH CIRCLE BACKGROUND +// +ImageWithCircleBackgroundLayoutElement::ImageWithCircleBackgroundLayoutElement( + MessageElement &creator, ImagePtr image, const QSize &imageSize, + QColor color, int padding) + : ImageLayoutElement(creator, image, + imageSize + QSize(padding, padding) * 2) + , color_(color) + , imageSize_(imageSize) + , padding_(padding) +{ +} + +void ImageWithCircleBackgroundLayoutElement::paint(QPainter &painter) +{ + if (this->image_ == nullptr) + { + return; + } + + auto pixmap = this->image_->pixmapOrLoad(); + if (pixmap && !this->image_->animated()) + { + QRectF boxRect(this->getRect()); + painter.setPen(Qt::NoPen); + painter.setBrush(QBrush(this->color_, Qt::SolidPattern)); + painter.drawEllipse(boxRect); + + QRectF imgRect; + imgRect.setTopLeft(boxRect.topLeft()); + imgRect.setSize(this->imageSize_); + imgRect.translate(this->padding_, this->padding_); + + painter.drawPixmap(imgRect, *pixmap, QRectF()); + } +} + // // TEXT // @@ -402,4 +441,67 @@ int TextIconLayoutElement::getXFromIndex(int index) } } +ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator, + const QSize &size, + float thickness, + float neededMargin) + : MessageLayoutElement(creator, size) + , pen_(QColor("#888"), thickness, Qt::SolidLine, Qt::RoundCap) + , neededMargin_(neededMargin) +{ +} + +void ReplyCurveLayoutElement::paint(QPainter &painter) +{ + QRectF paintRect(this->getRect()); + QPainterPath bezierPath; + + qreal top = paintRect.top() + paintRect.height() * 0.25; // 25% from top + qreal left = paintRect.left() + this->neededMargin_; + qreal bottom = paintRect.bottom() - this->neededMargin_; + QPointF startPoint(left, bottom); + QPointF controlPoint(left, top); + QPointF endPoint(paintRect.right(), top); + + // Create curve path + bezierPath.moveTo(startPoint); + bezierPath.quadTo(controlPoint, endPoint); + + // Render curve + painter.setPen(this->pen_); + painter.setRenderHint(QPainter::Antialiasing); + painter.drawPath(bezierPath); +} + +void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset) +{ +} + +int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const +{ + return 0; +} + +int ReplyCurveLayoutElement::getXFromIndex(int index) +{ + if (index <= 0) + { + return this->getRect().left(); + } + else + { + return this->getRect().right(); + } +} + +void ReplyCurveLayoutElement::addCopyTextToString(QString &str, int from, + int to) const +{ +} + +int ReplyCurveLayoutElement::getSelectionIndexCount() const +{ + return 1; +} + } // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 1c7db0374..4bf26ad29 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -93,6 +94,23 @@ private: QColor color_; }; +class ImageWithCircleBackgroundLayoutElement : public ImageLayoutElement +{ +public: + ImageWithCircleBackgroundLayoutElement(MessageElement &creator, + ImagePtr image, + const QSize &imageSize, QColor color, + int padding); + +protected: + void paint(QPainter &painter) override; + +private: + const QColor color_; + const QSize imageSize_; + const int padding_; +}; + // TEXT class TextLayoutElement : public MessageLayoutElement { @@ -142,4 +160,24 @@ private: QString line2; }; +class ReplyCurveLayoutElement : public MessageLayoutElement +{ +public: + ReplyCurveLayoutElement(MessageElement &creator, const QSize &size, + float thickness, float lMargin); + +protected: + void paint(QPainter &painter) override; + void paintAnimated(QPainter &painter, int yOffset) override; + int getMouseOverIndex(const QPoint &abs) const override; + int getXFromIndex(int index) override; + void addCopyTextToString(QString &str, int from = 0, + int to = INT_MAX) const override; + int getSelectionIndexCount() const override; + +private: + const QPen pen_; + const float neededMargin_; +}; + } // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 5f26ca47c..ca1411952 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -9,7 +9,6 @@ #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchHelpers.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" @@ -19,6 +18,7 @@ #include +#include #include namespace { @@ -242,6 +242,87 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, false, message->isAction()); } +std::vector IrcMessageHandler::parseMessageWithReply( + Channel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded) +{ + std::vector builtMessages; + + auto command = message->command(); + + if (command == "PRIVMSG") + { + auto privMsg = static_cast(message); + auto tc = dynamic_cast(channel); + if (!tc) + { + return this->parsePrivMessage(channel, privMsg); + } + + MessageParseArgs args; + TwitchMessageBuilder builder(channel, message, args, privMsg->content(), + privMsg->isAction()); + + this->populateReply(tc, message, otherLoaded, builder); + + if (!builder.isIgnored()) + { + builtMessages.emplace_back(builder.build()); + builder.triggerHighlights(); + } + } + else if (command == "USERNOTICE") + { + return this->parseUserNoticeMessage(channel, message); + } + else if (command == "NOTICE") + { + return this->parseNoticeMessage( + static_cast(message)); + } + + return builtMessages; +} + +void IrcMessageHandler::populateReply( + TwitchChannel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded, TwitchMessageBuilder &builder) +{ + const auto &tags = message->tags(); + if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) + { + const QString replyID = it.value().toString(); + auto threadIt = channel->threads_.find(replyID); + if (threadIt != channel->threads_.end()) + { + const auto owned = threadIt->second.lock(); + if (owned) + { + // Thread already exists (has a reply) + builder.setThread(owned); + return; + } + } + + // Thread does not yet exist, find root reply and create thread. + // Linear search is justified by the infrequent use of replies + for (auto &otherMsg : otherLoaded) + { + if (otherMsg->id == replyID) + { + // Found root reply message + std::shared_ptr newThread = + std::make_shared(otherMsg); + + builder.setThread(newThread); + // Store weak reference to thread in channel + channel->addReplyThread(newThread); + break; + } + } + } +} + void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, const QString &target, const QString &content, @@ -276,7 +357,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, auto channel = dynamic_cast(chan.get()); const auto &tags = _message->tags(); - if (const auto &it = tags.find("custom-reward-id"); it != tags.end()) + if (const auto it = tags.find("custom-reward-id"); it != tags.end()) { const auto rewardId = it.value().toString(); if (!channel->isChannelPointRewardKnown(rewardId)) @@ -301,6 +382,31 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction); + if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) + { + const QString replyID = it.value().toString(); + auto threadIt = channel->threads_.find(replyID); + if (threadIt != channel->threads_.end() && !threadIt->second.expired()) + { + // Thread already exists (has a reply) + builder.setThread(threadIt->second.lock()); + } + else + { + // Thread does not yet exist, find root reply and create thread. + auto root = channel->findMessage(replyID); + if (root) + { + // Found root reply message + const auto newThread = std::make_shared(root); + + builder.setThread(newThread); + // Store weak reference to thread in channel + channel->addReplyThread(newThread); + } + } + } + if (isSub || !builder.isIgnored()) { if (isSub) diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index caa8740b9..db3fb8c48 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -3,6 +3,10 @@ #include #include "common/Channel.hpp" #include "messages/Message.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +#include namespace chatterino { @@ -20,6 +24,10 @@ public: std::vector parseMessage(Channel *channel, Communi::IrcMessage *message); + std::vector parseMessageWithReply( + Channel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded); + // parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages std::vector parsePrivMessage( Channel *channel, Communi::IrcPrivateMessage *message); @@ -59,6 +67,10 @@ private: void addMessage(Communi::IrcMessage *message, const QString &target, const QString &content, TwitchIrcServer &server, bool isResub, bool isAction); + + void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded, + TwitchMessageBuilder &builder); }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 26e30a976..f866cbc38 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1,6 +1,5 @@ #include "providers/twitch/TwitchChannel.hpp" -#include "Application.hpp" #include "common/Common.hpp" #include "common/Env.hpp" #include "common/NetworkRequest.hpp" @@ -182,12 +181,34 @@ TwitchChannel::TwitchChannel(const QString &name) this->refreshBTTVChannelEmotes(false); }); + this->messageRemovedFromStart.connect([this](MessagePtr &msg) { + if (msg->replyThread) + { + if (msg->replyThread->liveCount(msg) == 0) + { + this->threads_.erase(msg->replyThread->rootId()); + } + } + }); + // timers QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] { this->refreshChatters(); }); this->chattersListTimer_.start(5 * 60 * 1000); + QObject::connect(&this->threadClearTimer_, &QTimer::timeout, [=] { + // We periodically check for any dangling reply threads that missed + // being cleaned up on messageRemovedFromStart. This could occur if + // some other part of the program, like a user card, held a reference + // to the message. + // + // It seems difficult to actually replicate a situation where things + // are actually cleaned up, but I've verified that cleanups DO happen. + this->cleanUpReplyThreads(); + }); + this->threadClearTimer_.start(5 * 60 * 1000); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -311,46 +332,34 @@ boost::optional TwitchChannel::channelPointReward( return it->second; } -void TwitchChannel::sendMessage(const QString &message) +void TwitchChannel::showLoginMessage() +{ + const auto linkColor = MessageColor(MessageColor::Link); + const auto accountsLink = Link(Link::OpenAccountsPage, QString()); + const auto currentUser = getApp()->accounts->twitch.getCurrent(); + const auto expirationText = + QStringLiteral("You need to log in to send messages. You can link your " + "Twitch account"); + const auto loginPromptText = QStringLiteral("in the settings."); + + auto builder = MessageBuilder(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder.emplace(); + builder.emplace(expirationText, MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(loginPromptText, MessageElementFlag::Text, + linkColor) + ->setLink(accountsLink); + + this->addMessage(builder.release()); +} + +QString TwitchChannel::prepareMessage(const QString &message) const { auto app = getApp(); - - if (!app->accounts->twitch.isLoggedIn()) - { - if (message.isEmpty()) - { - return; - } - - const auto linkColor = MessageColor(MessageColor::Link); - const auto accountsLink = Link(Link::OpenAccountsPage, QString()); - const auto currentUser = getApp()->accounts->twitch.getCurrent(); - const auto expirationText = - QString("You need to log in to send messages. You can link your " - "Twitch account"); - const auto loginPromptText = QString("in the settings."); - - auto builder = MessageBuilder(); - builder.message().flags.set(MessageFlag::System); - builder.message().flags.set(MessageFlag::DoNotTriggerNotification); - - builder.emplace(); - builder.emplace(expirationText, MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(loginPromptText, MessageElementFlag::Text, - linkColor) - ->setLink(accountsLink); - - this->addMessage(builder.release()); - - return; - } - - qCDebug(chatterinoTwitch) - << "[TwitchChannel" << this->getName() << "] Send message:" << message; - - // Do last message processing QString parsedMessage = app->emotes->emojis.replaceShortCodes(message); // This is to make sure that combined emoji go through properly, see @@ -361,7 +370,7 @@ void TwitchChannel::sendMessage(const QString &message) if (parsedMessage.isEmpty()) { - return; + return ""; } if (!this->hasHighRateLimit()) @@ -395,6 +404,32 @@ void TwitchChannel::sendMessage(const QString &message) } } + return parsedMessage; +} + +void TwitchChannel::sendMessage(const QString &message) +{ + auto app = getApp(); + if (!app->accounts->twitch.isLoggedIn()) + { + if (!message.isEmpty()) + { + this->showLoginMessage(); + } + + return; + } + + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Send message:" << message; + + // Do last message processing + QString parsedMessage = this->prepareMessage(message); + if (parsedMessage.isEmpty()) + { + return; + } + bool messageSent = false; this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent); @@ -405,6 +440,40 @@ void TwitchChannel::sendMessage(const QString &message) } } +void TwitchChannel::sendReply(const QString &message, const QString &replyId) +{ + auto app = getApp(); + if (!app->accounts->twitch.isLoggedIn()) + { + if (!message.isEmpty()) + { + this->showLoginMessage(); + } + + return; + } + + qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName() + << "] Send reply message:" << message; + + // Do last message processing + QString parsedMessage = this->prepareMessage(message); + if (parsedMessage.isEmpty()) + { + return; + } + + bool messageSent = false; + this->sendReplySignal.invoke(this->getName(), parsedMessage, replyId, + messageSent); + + if (messageSent) + { + qCDebug(chatterinoTwitch) << "sent"; + this->lastSentMessage_ = parsedMessage; + } +} + bool TwitchChannel::isMod() const { return this->mod_; @@ -794,8 +863,10 @@ void TwitchChannel::loadRecentMessages() } } - for (auto builtMessage : - handler.parseMessage(shared.get(), message)) + auto builtMessages = handler.parseMessageWithReply( + shared.get(), message, allBuiltMessages); + + for (auto builtMessage : builtMessages) { builtMessage->flags.set(MessageFlag::RecentMessage); allBuiltMessages.emplace_back(builtMessage); @@ -922,6 +993,39 @@ void TwitchChannel::fetchDisplayName() [] {}); } +void TwitchChannel::addReplyThread(const std::shared_ptr &thread) +{ + this->threads_[thread->rootId()] = thread; +} + +const std::unordered_map> + &TwitchChannel::threads() const +{ + return this->threads_; +} + +void TwitchChannel::cleanUpReplyThreads() +{ + for (auto it = this->threads_.begin(), last = this->threads_.end(); + it != last;) + { + bool doErase = true; + if (auto thread = it->second.lock()) + { + doErase = thread->liveCount() == 0; + } + + if (doErase) + { + it = this->threads_.erase(it); + } + else + { + ++it; + } + } +} + void TwitchChannel::refreshBadges() { auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" + diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 12f74374a..b605d9592 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -1,5 +1,6 @@ #pragma once +#include "Application.hpp" #include "common/Aliases.hpp" #include "common/Atomic.hpp" #include "common/Channel.hpp" @@ -7,6 +8,7 @@ #include "common/ChatterSet.hpp" #include "common/Outcome.hpp" #include "common/UniqueAccess.hpp" +#include "messages/MessageThread.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "providers/twitch/api/Helix.hpp" @@ -74,12 +76,15 @@ public: int slowMode = 0; }; + explicit TwitchChannel(const QString &channelName); + void initialize(); // Channel methods virtual bool isEmpty() const override; virtual bool canSendMessage() const override; virtual void sendMessage(const QString &message) override; + virtual void sendReply(const QString &message, const QString &replyId); virtual bool isMod() const override; bool isVip() const; bool isStaff() const; @@ -118,6 +123,17 @@ public: // Cheers boost::optional cheerEmote(const QString &string); + // Replies + /** + * Stores the given thread in this channel. + * + * Note: This method not take ownership of the MessageThread; this + * TwitchChannel instance will store a weak_ptr to the thread. + */ + void addReplyThread(const std::shared_ptr &thread); + const std::unordered_map> &threads() + const; + // Signals pajlada::Signals::NoArgSignal roomIdChanged; pajlada::Signals::NoArgSignal userStateChanged; @@ -138,9 +154,6 @@ private: QString localizedName; } nameOptions; -protected: - explicit TwitchChannel(const QString &channelName); - private: // Methods void refreshLiveStatus(); @@ -151,6 +164,8 @@ private: void refreshCheerEmotes(); void loadRecentMessages(); void fetchDisplayName(); + void cleanUpReplyThreads(); + void showLoginMessage(); void setLive(bool newLiveStatus); void setMod(bool value); @@ -164,6 +179,8 @@ private: const QString &getDisplayName() const override; const QString &getLocalizedName() const override; + QString prepareMessage(const QString &message) const; + // Data const QString subscriptionUrl_; const QString channelUrl_; @@ -171,6 +188,7 @@ private: int chatterCount_; UniqueAccess streamStatus_; UniqueAccess roomModes_; + std::unordered_map> threads_; protected: Atomic> bttvEmotes_; @@ -194,6 +212,7 @@ private: QString lastSentMessage_; QObject lifetimeGuard_; QTimer chattersListTimer_; + QTimer threadClearTimer_; QElapsedTimer titleRefreshedTimer_; QElapsedTimer clipCreationTimer_; bool isClipCreationInProgress{false}; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 1286deb41..4110142c7 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -121,6 +121,11 @@ std::shared_ptr TwitchIrcServer::createChannel( [this, channel = channel.get()](auto &chan, auto &msg, bool &sent) { this->onMessageSendRequested(channel, msg, sent); }); + channel->sendReplySignal.connect( + [this, channel = channel.get()](auto &chan, auto &msg, auto &replyId, + bool &sent) { + this->onReplySendRequested(channel, msg, replyId, sent); + }); return std::shared_ptr(channel); } @@ -370,66 +375,90 @@ bool TwitchIrcServer::hasSeparateWriteConnection() const // return getSettings()->twitchSeperateWriteConnection; } +bool TwitchIrcServer::prepareToSend(TwitchChannel *channel) +{ + std::lock_guard guard(this->lastMessageMutex_); + + auto &lastMessage = channel->hasHighRateLimit() ? this->lastMessageMod_ + : this->lastMessagePleb_; + size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19; + auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms); + + auto now = std::chrono::steady_clock::now(); + + // check if you are sending messages too fast + if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now) + { + if (this->lastErrorTimeSpeed_ + 30s < now) + { + auto errorMessage = + makeSystemMessage("You are sending messages too quickly."); + + channel->addMessage(errorMessage); + + this->lastErrorTimeSpeed_ = now; + } + return false; + } + + // remove messages older than 30 seconds + while (!lastMessage.empty() && lastMessage.front() + 32s < now) + { + lastMessage.pop(); + } + + // check if you are sending too many messages + if (lastMessage.size() >= maxMessageCount) + { + if (this->lastErrorTimeAmount_ + 30s < now) + { + auto errorMessage = + makeSystemMessage("You are sending too many messages."); + + channel->addMessage(errorMessage); + + this->lastErrorTimeAmount_ = now; + } + return false; + } + + lastMessage.push(now); + return true; +} + void TwitchIrcServer::onMessageSendRequested(TwitchChannel *channel, const QString &message, bool &sent) { sent = false; + bool canSend = this->prepareToSend(channel); + if (!canSend) { - std::lock_guard guard(this->lastMessageMutex_); - - // std::queue - auto &lastMessage = channel->hasHighRateLimit() - ? this->lastMessageMod_ - : this->lastMessagePleb_; - size_t maxMessageCount = channel->hasHighRateLimit() ? 99 : 19; - auto minMessageOffset = (channel->hasHighRateLimit() ? 100ms : 1100ms); - - auto now = std::chrono::steady_clock::now(); - - // check if you are sending messages too fast - if (!lastMessage.empty() && lastMessage.back() + minMessageOffset > now) - { - if (this->lastErrorTimeSpeed_ + 30s < now) - { - auto errorMessage = - makeSystemMessage("You are sending messages too quickly."); - - channel->addMessage(errorMessage); - - this->lastErrorTimeSpeed_ = now; - } - return; - } - - // remove messages older than 30 seconds - while (!lastMessage.empty() && lastMessage.front() + 32s < now) - { - lastMessage.pop(); - } - - // check if you are sending too many messages - if (lastMessage.size() >= maxMessageCount) - { - if (this->lastErrorTimeAmount_ + 30s < now) - { - auto errorMessage = - makeSystemMessage("You are sending too many messages."); - - channel->addMessage(errorMessage); - - this->lastErrorTimeAmount_ = now; - } - return; - } - - lastMessage.push(now); + return; } this->sendMessage(channel->getName(), message); sent = true; } +void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel, + const QString &message, + const QString &replyId, bool &sent) +{ + sent = false; + + bool canSend = this->prepareToSend(channel); + if (!canSend) + { + return; + } + + this->sendRawMessage("@reply-parent-msg-id=" + replyId + " PRIVMSG #" + + channel->getName() + " :" + message); + + sent = true; +} + const BttvEmotes &TwitchIrcServer::getBttvEmotes() const { return this->bttv; diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index fd693a206..323581c63 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -67,6 +67,10 @@ protected: private: void onMessageSendRequested(TwitchChannel *channel, const QString &message, bool &sent); + void onReplySendRequested(TwitchChannel *channel, const QString &message, + const QString &replyId, bool &sent); + + bool prepareToSend(TwitchChannel *channel); std::mutex lastMessageMutex_; std::queue lastMessagePleb_; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index c4fcd156f..b2eb7488d 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -48,6 +48,66 @@ const QSet zeroWidthEmotes{ namespace chatterino { +namespace { + + QString stylizeUsername(const QString &username, const Message &message) + { + auto app = getApp(); + + const QString &localizedName = message.localizedName; + bool hasLocalizedName = !localizedName.isEmpty(); + + // The full string that will be rendered in the chat widget + QString usernameText; + + switch (getSettings()->usernameDisplayMode.getValue()) + { + case UsernameDisplayMode::Username: { + usernameText = username; + } + break; + + case UsernameDisplayMode::LocalizedName: { + if (hasLocalizedName) + { + usernameText = localizedName; + } + else + { + usernameText = username; + } + } + break; + + default: + case UsernameDisplayMode::UsernameAndLocalizedName: { + if (hasLocalizedName) + { + usernameText = username + "(" + localizedName + ")"; + } + else + { + usernameText = username; + } + } + break; + } + + auto nicknames = getCSettings().nicknames.readOnly(); + + for (const auto &nickname : *nicknames) + { + if (nickname.match(usernameText)) + { + break; + } + } + + return usernameText; + } + +} // namespace + TwitchMessageBuilder::TwitchMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) @@ -138,6 +198,70 @@ MessagePtr TwitchMessageBuilder::build() this->message().flags.set(MessageFlag::FirstMessage); } + // reply threads + if (this->thread_) + { + // set references + this->message().replyThread = this->thread_; + this->thread_->addToThread(this->weakOf()); + + // enable reply flag + this->message().flags.set(MessageFlag::ReplyMessage); + + const auto &threadRoot = this->thread_->root(); + + QString usernameText = + stylizeUsername(threadRoot->loginName, *threadRoot.get()); + + this->emplace(); + + // construct reply elements + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + + this->emplace( + "@" + usernameText + ":", MessageElementFlag::RepliedMessage, + threadRoot->usernameColor, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, threadRoot->displayName}); + + this->emplace( + threadRoot->messageText, MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) + { + // Message is a reply but we couldn't find the original message. + // Render the message using the additional reply tags + + auto replyDisplayName = this->tags.find("reply-parent-display-name"); + auto replyBody = this->tags.find("reply-parent-msg-body"); + + if (replyDisplayName != this->tags.end() && + replyBody != this->tags.end()) + { + auto name = replyDisplayName->toString(); + auto body = parseTagString(replyBody->toString()); + + this->emplace(); + + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall); + + this->emplace( + "@" + name + ":", MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, name}); + + this->emplace( + body, MessageElementFlag::RepliedMessage, this->textColor_, + FontStyle::ChatMediumSmall); + } + } + // timestamp this->message().serverReceivedTime = calculateMessageTime(this->ircMessage); this->emplace(this->message().serverReceivedTime.time()); @@ -217,6 +341,23 @@ MessagePtr TwitchMessageBuilder::build() ColorProvider::instance().color(ColorType::Whisper); } + if (this->thread_) + { + auto &img = getResources().buttons.replyThreadDark; + this->emplace(Image::fromPixmap(img, 0.15), 2, + Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else + { + auto &img = getResources().buttons.replyDark; + this->emplace(Image::fromPixmap(img, 0.15), 2, + Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ReplyToMessage, this->message().id}); + } + return this->release(); } @@ -559,53 +700,7 @@ void TwitchMessageBuilder::appendUsername() } } - bool hasLocalizedName = !localizedName.isEmpty(); - - // The full string that will be rendered in the chat widget - QString usernameText; - - switch (getSettings()->usernameDisplayMode.getValue()) - { - case UsernameDisplayMode::Username: { - usernameText = username; - } - break; - - case UsernameDisplayMode::LocalizedName: { - if (hasLocalizedName) - { - usernameText = localizedName; - } - else - { - usernameText = username; - } - } - break; - - default: - case UsernameDisplayMode::UsernameAndLocalizedName: { - if (hasLocalizedName) - { - usernameText = username + "(" + localizedName + ")"; - } - else - { - usernameText = username; - } - } - break; - } - - auto nicknames = getCSettings().nicknames.readOnly(); - - for (const auto &nickname : *nicknames) - { - if (nickname.match(usernameText)) - { - break; - } - } + QString usernameText = stylizeUsername(username, this->message()); if (this->args.isSentWhisper) { @@ -1479,4 +1574,9 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, } } +void TwitchMessageBuilder::setThread(std::shared_ptr thread) +{ + this->thread_ = std::move(thread); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index c96712d61..130c89c3c 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -2,6 +2,7 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" +#include "messages/MessageThread.hpp" #include "messages/SharedMessageBuilder.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" @@ -45,6 +46,8 @@ public: void triggerHighlights() override; MessagePtr build() override; + void setThread(std::shared_ptr thread); + static void appendChannelPointRewardMessage( const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, bool isBroadcaster); @@ -105,6 +108,7 @@ private: int bitsLeft; bool bitsStacked = false; bool historicalMessage_ = false; + std::shared_ptr thread_; QString userId_; bool senderIsBroadcaster{}; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 439cd4327..38863c114 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -103,6 +103,7 @@ public: // BoolSetting collapseLongMessages = // {"/appearance/messages/collapseLongMessages", false}; + BoolSetting showReplyButton = {"/appearance/showReplyButton", false}; IntSetting collpseMessagesMinLines = { "/appearance/messages/collapseMessagesMinLines", 0}; BoolSetting alternateMessages = { @@ -158,6 +159,8 @@ public: FloatSetting mouseScrollMultiplier = {"/behaviour/mouseScrollMultiplier", 1.0}; BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true}; + BoolSetting autoCloseThreadPopup = {"/behaviour/autoCloseThreadPopup", + false}; // BoolSetting twitchSeperateWriteConnection = // {"/behaviour/twitchSeperateWriteConnection", false}; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 2c6e66131..f7f555d66 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -113,6 +113,7 @@ WindowManager::WindowManager() this->wordFlagsListener_.addSetting(settings->enableEmoteImages); this->wordFlagsListener_.addSetting(settings->boldUsernames); this->wordFlagsListener_.addSetting(settings->lowercaseDomains); + this->wordFlagsListener_.addSetting(settings->showReplyButton); this->wordFlagsListener_.setCB([this] { this->updateWordTypeMask(); }); @@ -182,6 +183,10 @@ void WindowManager::updateWordTypeMask() // username flags.set(MEF::Username); + // replies + flags.set(MEF::RepliedMessage); + flags.set(settings->showReplyButton ? MEF::ReplyButton : MEF::None); + // misc flags.set(MEF::AlwaysShow); flags.set(MEF::Collapsed); diff --git a/src/widgets/DraggablePopup.cpp b/src/widgets/DraggablePopup.cpp new file mode 100644 index 000000000..e103ac974 --- /dev/null +++ b/src/widgets/DraggablePopup.cpp @@ -0,0 +1,90 @@ +#include "DraggablePopup.hpp" + +#include + +#include + +namespace chatterino { + +namespace { + +#ifdef Q_OS_LINUX + FlagsEnum popupFlags{BaseWindow::Dialog, + BaseWindow::EnableCustomFrame}; + FlagsEnum popupFlagsCloseAutomatically{ + BaseWindow::EnableCustomFrame}; +#else + FlagsEnum popupFlags{BaseWindow::EnableCustomFrame}; + FlagsEnum popupFlagsCloseAutomatically{ + BaseWindow::EnableCustomFrame, BaseWindow::Frameless, + BaseWindow::FramelessDraggable}; +#endif + +} // namespace + +DraggablePopup::DraggablePopup(bool closeAutomatically, QWidget *parent) + : BaseWindow(closeAutomatically ? popupFlagsCloseAutomatically : popupFlags, + parent) + , lifetimeHack_(std::make_shared(false)) + , dragTimer_(this) + +{ + if (closeAutomatically) + { + this->setActionOnFocusLoss(BaseWindow::Delete); + } + else + { + this->setAttribute(Qt::WA_DeleteOnClose); + } + + // Update the window position according to this->requestedDragPos_ on every trigger + this->dragTimer_.callOnTimeout( + [this, hack = std::weak_ptr(this->lifetimeHack_)] { + if (!hack.lock()) + { + // Ensure this timer is never called after the object has been destroyed + return; + } + + if (!this->isMoving_) + { + return; + } + + this->move(this->requestedDragPos_); + }); +} + +void DraggablePopup::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::MouseButton::LeftButton) + { + this->dragTimer_.start(std::chrono::milliseconds(17)); + this->startPosDrag_ = event->pos(); + this->movingRelativePos = event->localPos(); + } +} + +void DraggablePopup::mouseReleaseEvent(QMouseEvent *event) +{ + this->dragTimer_.stop(); + this->isMoving_ = false; +} + +void DraggablePopup::mouseMoveEvent(QMouseEvent *event) +{ + // Drag the window by the amount changed from inital position + // Note that we provide a few *units* of deadzone so people don't + // start dragging the window if they are slow at clicking. + + auto movePos = event->pos() - this->startPosDrag_; + if (this->isMoving_ || movePos.manhattanLength() > 10.0) + { + this->requestedDragPos_ = + (event->screenPos() - this->movingRelativePos).toPoint(); + this->isMoving_ = true; + } +} + +} // namespace chatterino diff --git a/src/widgets/DraggablePopup.hpp b/src/widgets/DraggablePopup.hpp new file mode 100644 index 000000000..65050e15b --- /dev/null +++ b/src/widgets/DraggablePopup.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "widgets/BaseWindow.hpp" + +#include +#include + +#include + +namespace chatterino { + +class DraggablePopup : public BaseWindow +{ + Q_OBJECT + +public: + /// DraggablePopup implements the automatic dragging behavior when clicking + /// anywhere in the window (that doesn't have some other widget). + /// + /// If closeAutomatically is set, the window will close when losing focus, + /// and the window will be frameless. + DraggablePopup(bool closeAutomatically, QWidget *parent); + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + + // lifetimeHack_ is used to check that the window hasn't been destroyed yet + std::shared_ptr lifetimeHack_; + +private: + // isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_) + bool isMoving_ = false; + + // startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging + QPoint startPosDrag_; + + // requestDragPos_ is the final screen coordinates where the widget should be moved to. + // Takes the relative position of where the user originally clicked the widget into account + QPoint requestedDragPos_; + + // dragTimer_ is called ~60 times per second once the user has initiated dragging + QTimer dragTimer_; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp new file mode 100644 index 000000000..c4771de03 --- /dev/null +++ b/src/widgets/dialogs/ReplyThreadPopup.cpp @@ -0,0 +1,193 @@ +#include "ReplyThreadPopup.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" +#include "messages/MessageThread.hpp" +#include "util/LayoutCreator.hpp" +#include "widgets/Scrollbar.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/helper/ResizingTextEdit.hpp" +#include "widgets/splits/Split.hpp" +#include "widgets/splits/SplitInput.hpp" + +const QString TEXT_TITLE("Reply Thread - @%1 in #%2"); + +namespace chatterino { + +ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, + Split *split) + : DraggablePopup(closeAutomatically, parent) + , split_(split) +{ + this->setWindowTitle(QStringLiteral("Reply Thread")); + this->setStayInScreenRect(true); + + HotkeyController::HotkeyMap actions{ + {"delete", + [this](std::vector) -> QString { + this->deleteLater(); + return ""; + }}, + {"scrollPage", + [this](std::vector arguments) -> QString { + if (arguments.empty()) + { + qCWarning(chatterinoHotkeys) + << "scrollPage hotkey called without arguments!"; + return "scrollPage hotkey called without arguments!"; + } + auto direction = arguments.at(0); + + auto &scrollbar = this->ui_.threadView->getScrollBar(); + if (direction == "up") + { + scrollbar.offset(-scrollbar.getLargeChange()); + } + else if (direction == "down") + { + scrollbar.offset(scrollbar.getLargeChange()); + } + else + { + qCWarning(chatterinoHotkeys) << "Unknown scroll direction"; + } + return ""; + }}, + + // these actions make no sense in the context of a reply thread, so they aren't implemented + {"execModeratorAction", nullptr}, + {"reject", nullptr}, + {"accept", nullptr}, + {"openTab", nullptr}, + {"search", nullptr}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); + + auto layout = LayoutCreator(this->getLayoutContainer()) + .setLayoutType(); + + // initialize UI + this->ui_.threadView = + new ChannelView(this, this->split_, ChannelView::Context::ReplyThread); + this->ui_.threadView->setMinimumSize(400, 100); + this->ui_.threadView->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Expanding); + this->ui_.threadView->mouseDown.connect([this](QMouseEvent *) { + this->giveFocus(Qt::MouseFocusReason); + }); + + // Create SplitInput with inline replying disabled + this->ui_.replyInput = new SplitInput(this, this->split_, false); + + this->bSignals_.emplace_back( + getApp()->accounts->twitch.currentUserChanged.connect([this] { + this->updateInputUI(); + })); + + layout->setSpacing(0); + // provide draggable margin if frameless + layout->setMargin(closeAutomatically ? 15 : 1); + layout->addWidget(this->ui_.threadView, 1); + layout->addWidget(this->ui_.replyInput); +} + +void ReplyThreadPopup::setThread(std::shared_ptr thread) +{ + this->thread_ = std::move(thread); + this->ui_.replyInput->setReply(this->thread_); + this->addMessagesFromThread(); + this->updateInputUI(); +} + +void ReplyThreadPopup::addMessagesFromThread() +{ + this->ui_.threadView->clearMessages(); + if (!this->thread_) + { + return; + } + + const auto &sourceChannel = this->split_->getChannel(); + this->setWindowTitle(TEXT_TITLE.arg(this->thread_->root()->loginName, + sourceChannel->getName())); + + ChannelPtr virtualChannel; + if (sourceChannel->isTwitchChannel()) + { + virtualChannel = + std::make_shared(sourceChannel->getName()); + } + else + { + virtualChannel = std::make_shared(sourceChannel->getName(), + Channel::Type::None); + } + + this->ui_.threadView->setChannel(virtualChannel); + this->ui_.threadView->setSourceChannel(sourceChannel); + + virtualChannel->addMessage(this->thread_->root()); + for (const auto &msgRef : this->thread_->replies()) + { + if (auto msg = msgRef.lock()) + { + virtualChannel->addMessage(msg); + } + } + + this->messageConnection_ = + std::make_unique( + sourceChannel->messageAppended.connect( + [this, virtualChannel](MessagePtr &message, auto) { + if (message->replyThread == this->thread_) + { + // same reply thread, add message + virtualChannel->addMessage(message); + } + })); +} + +void ReplyThreadPopup::updateInputUI() +{ + auto channel = this->split_->getChannel(); + // Bail out if not a twitch channel. + // Special twitch channels will hide their reply input box. + if (!channel || !channel->isTwitchChannel()) + { + return; + } + + this->ui_.replyInput->setVisible(channel->isWritable()); + + auto user = getApp()->accounts->twitch.getCurrent(); + QString placeholderText; + + if (user->isAnon()) + { + placeholderText = QStringLiteral("Log in to send messages..."); + } + else + { + placeholderText = + QStringLiteral("Reply as %1...") + .arg(getApp()->accounts->twitch.getCurrent()->getUserName()); + } + + this->ui_.replyInput->setPlaceholderText(placeholderText); +} + +void ReplyThreadPopup::giveFocus(Qt::FocusReason reason) +{ + this->ui_.replyInput->giveFocus(reason); +} + +void ReplyThreadPopup::focusInEvent(QFocusEvent *event) +{ + this->giveFocus(event->reason()); +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/ReplyThreadPopup.hpp b/src/widgets/dialogs/ReplyThreadPopup.hpp new file mode 100644 index 000000000..863274e5f --- /dev/null +++ b/src/widgets/dialogs/ReplyThreadPopup.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "ForwardDecl.hpp" +#include "widgets/DraggablePopup.hpp" + +#include +#include +#include + +namespace chatterino { + +class MessageThread; +class Split; +class SplitInput; + +class ReplyThreadPopup final : public DraggablePopup +{ + Q_OBJECT + +public: + ReplyThreadPopup(bool closeAutomatically, QWidget *parent, Split *split); + + void setThread(std::shared_ptr thread); + void giveFocus(Qt::FocusReason reason); + +protected: + void focusInEvent(QFocusEvent *event) override; + +private: + void addMessagesFromThread(); + void updateInputUI(); + + // The message reply thread + std::shared_ptr thread_; + // The channel that the reply thread is in + ChannelPtr channel_; + Split *split_; + + struct { + ChannelView *threadView = nullptr; + SplitInput *replyInput = nullptr; + } ui_; + + std::unique_ptr messageConnection_; + std::vector bSignals_; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 957451c0c..a57fcedc5 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -91,8 +91,16 @@ namespace { LimitedQueueSnapshot snapshot = channel->getMessageSnapshot(); - ChannelPtr channelPtr( - new Channel(channel->getName(), Channel::Type::None)); + ChannelPtr channelPtr; + if (channel->isTwitchChannel()) + { + channelPtr = std::make_shared(channel->getName()); + } + else + { + channelPtr = std::make_shared(channel->getName(), + Channel::Type::None); + } for (size_t i = 0; i < snapshot.size(); i++) { @@ -118,33 +126,14 @@ namespace { } // namespace -#ifdef Q_OS_LINUX -FlagsEnum userInfoPopupFlags{BaseWindow::Dialog, - BaseWindow::EnableCustomFrame}; -FlagsEnum userInfoPopupFlagsCloseAutomatically{ - BaseWindow::EnableCustomFrame}; -#else -FlagsEnum userInfoPopupFlags{BaseWindow::EnableCustomFrame}; -FlagsEnum userInfoPopupFlagsCloseAutomatically{ - BaseWindow::EnableCustomFrame, BaseWindow::Frameless, - BaseWindow::FramelessDraggable}; -#endif - -UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent) - : BaseWindow(closeAutomatically ? userInfoPopupFlagsCloseAutomatically - : userInfoPopupFlags, - parent) - , hack_(new bool) - , dragTimer_(this) +UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, + Split *split) + : DraggablePopup(closeAutomatically, parent) + , split_(split) { this->setWindowTitle("Usercard"); this->setStayInScreenRect(true); - if (closeAutomatically) - this->setActionOnFocusLoss(BaseWindow::Delete); - else - this->setAttribute(Qt::WA_DeleteOnClose); - HotkeyController::HotkeyMap actions{ {"delete", [this](std::vector) -> QString { @@ -498,7 +487,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent) this->ui_.noMessagesLabel = new Label("No recent messages"); this->ui_.noMessagesLabel->setVisible(false); - this->ui_.latestMessages = new ChannelView(this); + this->ui_.latestMessages = + new ChannelView(this, this->split_, ChannelView::Context::UserCard); this->ui_.latestMessages->setMinimumSize(400, 275); this->ui_.latestMessages->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -510,21 +500,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent) this->installEvents(); this->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Policy::Ignored); - - this->dragTimer_.callOnTimeout( - [this, hack = std::weak_ptr(this->hack_)] { - if (!hack.lock()) - { - // Ensure this timer is never called after the object has been destroyed - return; - } - if (!this->isMoving_) - { - return; - } - - this->move(this->requestedDragPos_); - }); } void UserInfoPopup::themeChangedEvent() @@ -550,36 +525,6 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/) }); } -void UserInfoPopup::mousePressEvent(QMouseEvent *event) -{ - if (event->button() == Qt::MouseButton::LeftButton) - { - this->dragTimer_.start(std::chrono::milliseconds(17)); - this->startPosDrag_ = event->pos(); - this->movingRelativePos = event->localPos(); - } -} - -void UserInfoPopup::mouseReleaseEvent(QMouseEvent *event) -{ - this->dragTimer_.stop(); - this->isMoving_ = false; -} - -void UserInfoPopup::mouseMoveEvent(QMouseEvent *event) -{ - // Drag the window by the amount changed from inital position - // Note that we provide a few *units* of deadzone so people don't - // start dragging the window if they are slow at clicking. - auto movePos = event->pos() - this->startPosDrag_; - if (this->isMoving_ || movePos.manhattanLength() > 10.0) - { - this->requestedDragPos_ = - (event->screenPos() - this->movingRelativePos).toPoint(); - this->isMoving_ = true; - } -} - void UserInfoPopup::installEvents() { std::shared_ptr ignoreNext = std::make_shared(false); @@ -765,7 +710,7 @@ void UserInfoPopup::updateLatestMessages() void UserInfoPopup::updateUserData() { - std::weak_ptr hack = this->hack_; + std::weak_ptr hack = this->lifetimeHack_; auto currentUser = getApp()->accounts->twitch.getCurrent(); const auto onUserFetchFailed = [this, hack] { diff --git a/src/widgets/dialogs/UserInfoPopup.hpp b/src/widgets/dialogs/UserInfoPopup.hpp index 2c7e1cf04..d87651bab 100644 --- a/src/widgets/dialogs/UserInfoPopup.hpp +++ b/src/widgets/dialogs/UserInfoPopup.hpp @@ -1,6 +1,6 @@ #pragma once -#include "widgets/BaseWindow.hpp" +#include "widgets/DraggablePopup.hpp" #include "widgets/helper/ChannelView.hpp" #include @@ -16,12 +16,13 @@ class Channel; using ChannelPtr = std::shared_ptr; class Label; -class UserInfoPopup final : public BaseWindow +class UserInfoPopup final : public DraggablePopup { Q_OBJECT public: - UserInfoPopup(bool closeAutomatically, QWidget *parent); + UserInfoPopup(bool closeAutomatically, QWidget *parent, + Split *split = nullptr); void setData(const QString &name, const ChannelPtr &channel); void setData(const QString &name, const ChannelPtr &contextChannel, @@ -30,9 +31,6 @@ public: protected: virtual void themeChangedEvent() override; virtual void scaleChangedEvent(float scale) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; private: void installEvents(); @@ -43,6 +41,8 @@ private: bool isMod_; bool isBroadcaster_; + Split *split_; + QString userName_; QString userId_; QString avatarUrl_; @@ -51,25 +51,10 @@ private: // The channel the messages are rendered from (e.g. #forsen). Can be a special channel, but will try to not be where possible. ChannelPtr underlyingChannel_; - // isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_) - bool isMoving_ = false; - - // startPosDrag_ is the coordinates where the user originally pressed the mouse button down to start dragging - QPoint startPosDrag_; - - // requestDragPos_ is the final screen coordinates where the widget should be moved to. - // Takes the relative position of where the user originally clicked the widget into account - QPoint requestedDragPos_; - - // dragTimer_ is called ~60 times per second once the user has initiated dragging - QTimer dragTimer_; - pajlada::Signals::NoArgSignal userStateChanged_; std::unique_ptr refreshConnection_; - std::shared_ptr hack_; - struct { Button *avatarButton = nullptr; Button *localizedNameCopyButton = nullptr; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 96fef73cb..09696c5bc 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -44,6 +44,7 @@ #include "widgets/Scrollbar.hpp" #include "widgets/TooltipWidget.hpp" #include "widgets/Window.hpp" +#include "widgets/dialogs/ReplyThreadPopup.hpp" #include "widgets/dialogs/SettingsDialog.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/helper/EffectLabel.hpp" @@ -119,9 +120,11 @@ namespace { } } // namespace -ChannelView::ChannelView(BaseWidget *parent) +ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) : BaseWidget(parent) , scrollBar_(new Scrollbar(this)) + , split_(split) + , context_(context) { this->setMouseTracking(true); @@ -160,7 +163,7 @@ ChannelView::ChannelView(BaseWidget *parent) // and tabbing to it from another widget. I don't currently know // of any place where you can, or where it would make sense, // to tab to a ChannelVieChannelView - this->setFocusPolicy(Qt::FocusPolicy::StrongFocus); + this->setFocusPolicy(Qt::FocusPolicy::ClickFocus); } void ChannelView::initializeLayout() @@ -1019,6 +1022,17 @@ MessageElementFlags ChannelView::getFlags() const if (this->sourceChannel_ == app->twitch->mentionsChannel) flags.set(MessageElementFlag::ChannelName); + if (this->context_ == Context::ReplyThread) + { + // Don't show inline replies within the ReplyThreadPopup + flags.unset(MessageElementFlag::RepliedMessage); + } + + if (!this->canReplyToMessages()) + { + flags.unset(MessageElementFlag::ReplyButton); + } + return flags; } @@ -1983,10 +1997,27 @@ void ChannelView::addMessageContextMenuItems( menu.addAction("Copy full message", [layout] { QString copyString; - layout->addSelectionText(copyString); + layout->addSelectionText(copyString, 0, INT_MAX, + CopyMode::EverythingButReplies); crossPlatformCopy(copyString); }); + + // Only display reply option where it makes sense + if (this->canReplyToMessages() && layout->isReplyable()) + { + const auto &messagePtr = layout->getMessagePtr(); + menu.addAction("Reply to message", [this, &messagePtr] { + this->setInputReply(messagePtr); + }); + + if (messagePtr->replyThread != nullptr) + { + menu.addAction("View thread", [this, &messagePtr] { + this->showReplyThreadPopup(messagePtr); + }); + } + } } void ChannelView::addTwitchLinkContextMenuItems( @@ -2224,8 +2255,8 @@ void ChannelView::showUserInfoPopup(const QString &userName, { auto *userCardParent = static_cast(&(getApp()->windows->getMainWindow())); - auto *userPopup = - new UserInfoPopup(getSettings()->autoCloseUserPopup, userCardParent); + auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup, + userCardParent, this->split_); auto contextChannel = getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel); @@ -2351,6 +2382,14 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, this->underlyingChannel_.get()->reconnect(); } break; + case Link::ReplyToMessage: { + this->setInputReply(layout->getMessagePtr()); + } + break; + case Link::ViewThread: { + this->showReplyThreadPopup(layout->getMessagePtr()); + } + break; default:; } @@ -2473,4 +2512,106 @@ void ChannelView::scrollUpdateRequested() this->scrollBar_->offset(multiplier * offset); } +void ChannelView::setInputReply(const MessagePtr &message) +{ + if (message == nullptr) + { + return; + } + + std::shared_ptr thread; + + if (message->replyThread == nullptr) + { + auto getThread = [&](TwitchChannel *tc) { + auto threadIt = tc->threads().find(message->id); + if (threadIt != tc->threads().end() && !threadIt->second.expired()) + { + return threadIt->second.lock(); + } + else + { + auto thread = std::make_shared(message); + tc->addReplyThread(thread); + return thread; + } + }; + + if (auto tc = + dynamic_cast(this->underlyingChannel_.get())) + { + thread = getThread(tc); + } + else if (auto tc = dynamic_cast(this->channel_.get())) + { + thread = getThread(tc); + } + else + { + qCWarning(chatterinoCommon) << "Failed to create new reply thread"; + // Unable to create new reply thread. + // TODO(dnsge): Should probably notify user? + return; + } + } + else + { + thread = message->replyThread; + } + + this->split_->setInputReply(thread); +} + +void ChannelView::showReplyThreadPopup(const MessagePtr &message) +{ + if (message == nullptr || message->replyThread == nullptr) + { + return; + } + + auto popupParent = + static_cast(&(getApp()->windows->getMainWindow())); + auto popup = new ReplyThreadPopup(getSettings()->autoCloseThreadPopup, + popupParent, this->split_); + + popup->setThread(message->replyThread); + + QPoint offset(int(150 * this->scale()), int(70 * this->scale())); + popup->move(QCursor::pos() - offset); + popup->show(); + popup->giveFocus(Qt::MouseFocusReason); +} + +ChannelView::Context ChannelView::getContext() const +{ + return this->context_; +} + +bool ChannelView::canReplyToMessages() const +{ + if (this->context_ == ChannelView::Context::ReplyThread || + this->context_ == ChannelView::Context::Search) + { + return false; + } + + if (this->channel_ == nullptr) + { + return false; + } + + if (!this->channel_->isTwitchChannel()) + { + return false; + } + + if (this->channel_->getType() == Channel::Type::TwitchWhispers || + this->channel_->getType() == Channel::Type::TwitchLive) + { + return false; + } + + return true; +} + } // namespace chatterino diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 1aabbf62f..34e4ba463 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -39,6 +39,7 @@ class Scrollbar; class EffectLabel; struct Link; class MessageLayoutElement; +class Split; enum class PauseReason { Mouse, @@ -61,7 +62,15 @@ class ChannelView final : public BaseWidget Q_OBJECT public: - explicit ChannelView(BaseWidget *parent = nullptr); + enum class Context { + None, + UserCard, + ReplyThread, + Search, + }; + + explicit ChannelView(BaseWidget *parent = nullptr, Split *split = nullptr, + Context context = Context::None); void queueUpdate(); Scrollbar &getScrollBar(); @@ -99,6 +108,8 @@ public: void clearMessages(); + Context getContext() const; + /** * @brief Creates and shows a UserInfoPopup dialog * @@ -196,6 +207,10 @@ private: void enableScrolling(const QPointF &scrollStart); void disableScrolling(); + void setInputReply(const MessagePtr &message); + void showReplyThreadPopup(const MessagePtr &message); + bool canReplyToMessages() const; + QTimer *layoutCooldown_; bool layoutQueued_; @@ -221,6 +236,7 @@ private: ChannelPtr channel_ = nullptr; ChannelPtr underlyingChannel_ = nullptr; ChannelPtr sourceChannel_ = nullptr; + Split *split_ = nullptr; Scrollbar *scrollBar_; EffectLabel *goToBottom_; @@ -264,6 +280,8 @@ private: Selection selection_; bool selecting_ = false; + const Context context_; + LimitedQueue messages_; pajlada::Signals::SignalHolder signalHolder_; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index 9b255d27d..d33465499 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -49,8 +49,9 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, return channel; } -SearchPopup::SearchPopup(QWidget *parent) +SearchPopup::SearchPopup(QWidget *parent, Split *split) : BasePopup({}, parent) + , split_(split) { this->initLayout(); this->resize(400, 600); @@ -238,7 +239,8 @@ void SearchPopup::initLayout() // CHANNELVIEW { - this->channelView_ = new ChannelView(this); + this->channelView_ = new ChannelView(this, this->split_, + ChannelView::Context::Search); layout1->addWidget(this->channelView_); } diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp index 61c0f9719..c927ff5b1 100644 --- a/src/widgets/helper/SearchPopup.hpp +++ b/src/widgets/helper/SearchPopup.hpp @@ -5,6 +5,7 @@ #include "messages/LimitedQueueSnapshot.hpp" #include "messages/search/MessagePredicate.hpp" #include "widgets/BasePopup.hpp" +#include "widgets/splits/Split.hpp" #include @@ -15,7 +16,7 @@ namespace chatterino { class SearchPopup : public BasePopup { public: - SearchPopup(QWidget *parent); + SearchPopup(QWidget *parent, Split *split = nullptr); virtual void addChannel(ChannelView &channel); @@ -58,6 +59,7 @@ private: QLineEdit *searchInput_{}; ChannelView *channelView_{}; QString channelName_{}; + Split *split_ = nullptr; QList> searchChannels_; }; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 8655b6c42..a09b6bdb6 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -185,6 +185,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) tabDirectionDropdown->setMinimumWidth( tabDirectionDropdown->minimumSizeHint().width()); + layout.addCheckbox("Show message reply button", s.showReplyButton); layout.addCheckbox("Show tab close button", s.showTabCloseButton); layout.addCheckbox("Always on top", s.windowTopMost); #ifdef USEWINSDK @@ -641,6 +642,9 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Show parted users (< 1000 chatters)", s.showParts); layout.addCheckbox("Automatically close user popup when it loses focus", s.autoCloseUserPopup); + layout.addCheckbox( + "Automatically close reply thread popup when it loses focus", + s.autoCloseThreadPopup); layout.addCheckbox("Lowercase domains (anti-phishing)", s.lowercaseDomains); layout.addCheckbox("Bold @usernames", s.boldUsernames); layout.addCheckbox("Color @usernames", s.colorUsernames); diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index d4b8a1ba5..c7c0d7579 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -9,6 +9,7 @@ #include "controllers/commands/CommandController.hpp" #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "messages/MessageThread.hpp" #include "providers/twitch/EmoteValue.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -85,7 +86,7 @@ Split::Split(QWidget *parent) , channel_(Channel::getEmpty()) , vbox_(new QVBoxLayout(this)) , header_(new SplitHeader(this)) - , view_(new ChannelView(this)) + , view_(new ChannelView(this, this)) , input_(new SplitInput(this)) , overlay_(new SplitOverlay(this)) { @@ -1164,7 +1165,7 @@ const QList Split::getFilters() const void Split::showSearch(bool singleChannel) { - auto *popup = new SearchPopup(this); + auto *popup = new SearchPopup(this, this); popup->setAttribute(Qt::WA_DeleteOnClose); if (singleChannel) @@ -1269,6 +1270,11 @@ void Split::drag() } } +void Split::setInputReply(const std::shared_ptr &reply) +{ + this->input_->setReply(reply); +} + } // namespace chatterino QDebug operator<<(QDebug dbg, const chatterino::Split &split) diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index 06d36fcd2..25190a797 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -15,6 +15,7 @@ namespace chatterino { class ChannelView; +class MessageThread; class SplitHeader; class SplitInput; class SplitContainer; @@ -74,6 +75,8 @@ public: void setContainer(SplitContainer *container); + void setInputReply(const std::shared_ptr &reply); + static pajlada::Signals::Signal modifierStatusChanged; static Qt::KeyboardModifiers modifierStatus; diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 0188f2ced..4a4c73f2d 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -23,15 +23,24 @@ #include "widgets/splits/SplitContainer.hpp" #include "widgets/splits/SplitInput.hpp" +#include + #include #include namespace chatterino { const int TWITCH_MESSAGE_LIMIT = 500; -SplitInput::SplitInput(Split *_chatWidget) - : BaseWidget(_chatWidget) +SplitInput::SplitInput(Split *_chatWidget, bool enableInlineReplying) + : SplitInput(_chatWidget, _chatWidget, enableInlineReplying) +{ +} + +SplitInput::SplitInput(QWidget *parent, Split *_chatWidget, + bool enableInlineReplying) + : BaseWidget(parent) , split_(_chatWidget) + , enableInlineReplying_(enableInlineReplying) { this->installEventFilter(this); this->initLayout(); @@ -66,17 +75,43 @@ void SplitInput::initLayout() LayoutCreator layoutCreator(this); auto layout = - layoutCreator.setLayoutType().withoutMargin().assign( - &this->ui_.hbox); + layoutCreator.setLayoutType().withoutMargin().assign( + &this->ui_.vbox); + + // reply label stuff + auto replyWrapper = + layout.emplace().assign(&this->ui_.replyWrapper); + this->ui_.replyWrapper->setContentsMargins(0, 0, 0, 0); + + auto replyHbox = replyWrapper.emplace().withoutMargin().assign( + &this->ui_.replyHbox); + + auto replyLabel = replyHbox.emplace().assign(&this->ui_.replyLabel); + replyLabel->setAlignment(Qt::AlignLeft); + replyLabel->setFont( + app->fonts->getFont(FontStyle::ChatMedium, this->scale())); + + replyHbox->addStretch(1); + + auto replyCancelButton = replyHbox.emplace(nullptr, 4) + .assign(&this->ui_.cancelReplyButton); + replyCancelButton->getLabel().setTextFormat(Qt::RichText); + + replyCancelButton->hide(); + replyLabel->hide(); + + // hbox for input, right box + auto hboxLayout = + layout.emplace().withoutMargin().assign(&this->ui_.hbox); // input auto textEdit = - layout.emplace().assign(&this->ui_.textEdit); + hboxLayout.emplace().assign(&this->ui_.textEdit); connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this, &SplitInput::editTextChanged); // right box - auto box = layout.emplace().withoutMargin(); + auto box = hboxLayout.emplace().withoutMargin(); box->setSpacing(0); { auto textEditLength = @@ -102,6 +137,8 @@ void SplitInput::initLayout() this->managedConnections_.managedConnect(app->fonts->fontChanged, [=]() { this->ui_.textEdit->setFont( app->fonts->getFont(FontStyle::ChatMedium, this->scale())); + this->ui_.replyLabel->setFont( + app->fonts->getFont(FontStyle::ChatMediumBold, this->scale())); }); // open emote popup @@ -109,6 +146,12 @@ void SplitInput::initLayout() this->openEmotePopup(); }); + // clear input and remove reply thread + QObject::connect(this->ui_.cancelReplyButton, &EffectLabel::leftClicked, + [=] { + this->clearInput(); + }); + // clear channelview selection when selecting in the input QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable, [this](bool available) { @@ -129,8 +172,10 @@ void SplitInput::initLayout() void SplitInput::scaleChangedEvent(float scale) { - // update the icon size of the emote button + auto app = getApp(); + // update the icon size of the buttons this->updateEmoteButton(); + this->updateCancelReplyButton(); // set maximum height if (!this->hidden) @@ -138,9 +183,11 @@ void SplitInput::scaleChangedEvent(float scale) this->setMaximumHeight(this->scaledMaxHeight()); } this->ui_.textEdit->setFont( - getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale())); + app->fonts->getFont(FontStyle::ChatMedium, this->scale())); this->ui_.textEditLength->setFont( - getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale())); + app->fonts->getFont(FontStyle::ChatMedium, this->scale())); + this->ui_.replyLabel->setFont( + app->fonts->getFont(FontStyle::ChatMediumBold, this->scale())); } void SplitInput::themeChangedEvent() @@ -155,6 +202,7 @@ void SplitInput::themeChangedEvent() #endif this->updateEmoteButton(); + this->updateCancelReplyButton(); this->ui_.textEditLength->setPalette(palette); this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet); @@ -162,10 +210,19 @@ void SplitInput::themeChangedEvent() this->ui_.textEdit->setPalette(placeholderPalette); #endif - this->ui_.hbox->setMargin( + this->ui_.vbox->setMargin( int((this->theme->isLightTheme() ? 4 : 2) * this->scale())); this->ui_.emoteButton->getLabel().setStyleSheet("color: #000"); + + if (this->theme->isLightTheme()) + { + this->ui_.replyLabel->setStyleSheet("color: #333"); + } + else + { + this->ui_.replyLabel->setStyleSheet("color: #ccc"); + } } void SplitInput::updateEmoteButton() @@ -184,6 +241,24 @@ void SplitInput::updateEmoteButton() this->ui_.emoteButton->setFixedHeight(int(18 * scale)); } +void SplitInput::updateCancelReplyButton() +{ + float scale = this->scale(); + + QString text = + QStringLiteral( + "") + .arg(QString::number(int(12 * scale))); + + if (this->theme->isLightTheme()) + { + text.replace("cancel", "cancelDark"); + } + + this->ui_.cancelReplyButton->getLabel().setText(text); + this->ui_.cancelReplyButton->setFixedHeight(int(12 * scale)); +} + void SplitInput::openEmotePopup() { if (!this->emotePopup_) @@ -217,6 +292,79 @@ void SplitInput::openEmotePopup() this->emotePopup_->activateWindow(); } +QString SplitInput::handleSendMessage(std::vector &arguments) +{ + auto c = this->split_->getChannel(); + if (c == nullptr) + return ""; + + if (!c->isTwitchChannel() || this->replyThread_ == nullptr) + { + // standard message send behavior + QString message = ui_.textEdit->toPlainText(); + + message = message.replace('\n', ' '); + QString sendMessage = + getApp()->commands->execCommand(message, c, false); + + c->sendMessage(sendMessage); + + this->postMessageSend(message, arguments); + return ""; + } + else + { + // 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->replyThread_->root()->displayName.length() + + 1); // remove "@username" + + if (!message.isEmpty() && message.at(0) == ' ') + { + message.remove(0, 1); // remove possible space + } + } + + message = message.replace('\n', ' '); + QString sendMessage = + getApp()->commands->execCommand(message, c, false); + + // Reply within TwitchChannel + tc->sendReply(sendMessage, this->replyThread_->rootId()); + + this->postMessageSend(message, arguments); + return ""; + } +} + +void SplitInput::postMessageSend(const QString &message, + const std::vector &arguments) +{ + // don't add duplicate messages and empty message to message history + if ((this->prevMsg_.isEmpty() || !this->prevMsg_.endsWith(message)) && + !message.trimmed().isEmpty()) + { + this->prevMsg_.append(message); + } + + if (arguments.empty() || arguments.at(0) != "keepInput") + { + this->clearInput(); + } + this->prevIndex_ = this->prevMsg_.size(); +} + int SplitInput::scaledMaxHeight() const { return int(150 * this->scale()); @@ -302,37 +450,7 @@ void SplitInput::addShortcuts() }}, {"sendMessage", [this](std::vector arguments) -> QString { - auto c = this->split_->getChannel(); - if (c == nullptr) - return ""; - - QString message = ui_.textEdit->toPlainText(); - - message = message.replace('\n', ' '); - QString sendMessage = - getApp()->commands->execCommand(message, c, false); - - c->sendMessage(sendMessage); - // don't add duplicate messages and empty message to message history - if ((this->prevMsg_.isEmpty() || - !this->prevMsg_.endsWith(message)) && - !message.trimmed().isEmpty()) - { - this->prevMsg_.append(message); - } - bool shouldClearInput = true; - if (arguments.size() != 0 && arguments.at(0) == "keepInput") - { - shouldClearInput = false; - } - - if (shouldClearInput) - { - this->currMsg_ = QString(); - this->ui_.textEdit->setPlainText(QString()); - } - this->prevIndex_ = this->prevMsg_.size(); - return ""; + return this->handleSendMessage(arguments); }}, {"previousMessage", [this](std::vector) -> QString { @@ -456,8 +574,7 @@ void SplitInput::addShortcuts() }}, {"clear", [this](std::vector) -> QString { - this->ui_.textEdit->setText(""); - this->ui_.textEdit->moveCursor(QTextCursor::Start); + this->clearInput(); return ""; }}, {"selectAll", @@ -769,37 +886,71 @@ void SplitInput::editTextChanged() } this->ui_.textEditLength->setText(labelText); + + bool hasReply = false; + if (this->enableInlineReplying_) + { + if (this->replyThread_ != 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_->root()->displayName; + if (!text.startsWith(replyPrefix) || + (text.length() > replyPrefix.length() && + text.at(replyPrefix.length()) != ' ')) + { + this->replyThread_ = nullptr; + } + } + + // Show/hide reply label if inline replies are possible + hasReply = this->replyThread_ != nullptr; + } + + this->ui_.replyWrapper->setVisible(hasReply); + this->ui_.replyLabel->setVisible(hasReply); + this->ui_.cancelReplyButton->setVisible(hasReply); } void SplitInput::paintEvent(QPaintEvent * /*event*/) { QPainter painter(this); + int s; + QColor borderColor; + if (this->theme->isLightTheme()) { - int s = int(3 * this->scale()); - QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s)); - - painter.fillRect(rect, this->theme->splits.input.background); - - painter.setPen(QColor("#ccc")); - painter.drawRect(rect); + s = int(3 * this->scale()); + borderColor = QColor("#ccc"); } else { - int s = int(1 * this->scale()); - QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s)); - - painter.fillRect(rect, this->theme->splits.input.background); - - painter.setPen(QColor("#333")); - painter.drawRect(rect); + s = int(1 * this->scale()); + borderColor = QColor("#333"); } - // int offset = 2; - // painter.fillRect(offset, this->height() - offset, this->width() - 2 * - // offset, 1, - // getApp()->themes->splits.input.focusedLine); + QMargins removeMargins(s - 1, s - 1, s, s); + QRect baseRect = this->rect(); + + // completeAreaRect includes the reply label + QRect completeAreaRect = baseRect.marginsRemoved(removeMargins); + painter.fillRect(completeAreaRect, this->theme->splits.input.background); + painter.setPen(borderColor); + painter.drawRect(completeAreaRect); + + if (this->enableInlineReplying_ && this->replyThread_ != nullptr) + { + // Move top of rect down to not include reply label + baseRect.setTop(baseRect.top() + this->ui_.replyWrapper->height()); + + QRect onlyInputRect = baseRect.marginsRemoved(removeMargins); + painter.setPen(borderColor); + painter.drawRect(onlyInputRect); + } } void SplitInput::resizeEvent(QResizeEvent *) @@ -814,4 +965,41 @@ void SplitInput::resizeEvent(QResizeEvent *) } } +void SplitInput::giveFocus(Qt::FocusReason reason) +{ + this->ui_.textEdit->setFocus(reason); +} + +void SplitInput::setReply(std::shared_ptr reply, + bool showReplyingLabel) +{ + this->replyThread_ = std::move(reply); + + if (this->enableInlineReplying_) + { + // Only enable reply label if inline replying + this->ui_.textEdit->setPlainText( + "@" + this->replyThread_->root()->displayName + " "); + this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); + this->ui_.replyLabel->setText("Replying to @" + + this->replyThread_->root()->displayName); + } +} + +void SplitInput::setPlaceholderText(const QString &text) +{ + this->ui_.textEdit->setPlaceholderText(text); +} + +void SplitInput::clearInput() +{ + this->currMsg_ = ""; + this->ui_.textEdit->setText(""); + this->ui_.textEdit->moveCursor(QTextCursor::Start); + if (this->enableInlineReplying_) + { + this->replyThread_ = nullptr; + } +} + } // namespace chatterino diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index 7296dc8b3..bcb4d149e 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace chatterino { @@ -18,6 +19,7 @@ class Split; class EmotePopup; class InputCompletionPopup; class EffectLabel; +class MessageThread; class ResizingTextEdit; class SplitInput : public BaseWidget @@ -25,13 +27,19 @@ class SplitInput : public BaseWidget Q_OBJECT public: - SplitInput(Split *_chatWidget); + SplitInput(Split *_chatWidget, bool enableInlineReplying = true); + SplitInput(QWidget *parent, Split *_chatWidget, + bool enableInlineReplying = true); void clearSelection(); bool isEditFirstWord() const; QString getInputText() const; void insertText(const QString &text); + void setReply(std::shared_ptr reply, + bool showInlineReplying = true); + void setPlaceholderText(const QString &text); + /** * @brief Hide the widget * @@ -64,7 +72,16 @@ protected: void paintEvent(QPaintEvent * /*event*/) override; void resizeEvent(QResizeEvent * /*event*/) override; -private: + virtual void giveFocus(Qt::FocusReason reason); + + QString handleSendMessage(std::vector &arguments); + void postMessageSend(const QString &message, + const std::vector &arguments); + + /// Clears the input box, clears reply thread if inline replies are enabled + void clearInput(); + +protected: void addShortcuts() override; void initLayout(); bool eventFilter(QObject *obj, QEvent *event) override; @@ -78,6 +95,8 @@ private: void insertCompletionText(const QString &text); void openEmotePopup(); + void updateCancelReplyButton(); + // scaledMaxHeight returns the height in pixels that this widget can grow to // This does not take hidden into account, so callers must take hidden into account themselves int scaledMaxHeight() const; @@ -92,8 +111,17 @@ private: EffectLabel *emoteButton; QHBoxLayout *hbox; + QVBoxLayout *vbox; + + QWidget *replyWrapper; + QHBoxLayout *replyHbox; + QLabel *replyLabel; + EffectLabel *cancelReplyButton; } ui_; + std::shared_ptr replyThread_ = nullptr; + bool enableInlineReplying_; + pajlada::Signals::SignalHolder managedConnections_; QStringList prevMsg_; QString currMsg_; @@ -109,6 +137,7 @@ private slots: void editTextChanged(); friend class Split; + friend class ReplyThreadPopup; }; } // namespace chatterino